commit acd8e21031332fe72d52a714e9cd1bf3af919744 Author: wehub-resource-sync Date: Mon Jul 13 13:18:05 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..741b01a --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1 @@ +sandbox_mode = "workspace-write" diff --git a/.codex/skills/aholo-docs/SKILL.md b/.codex/skills/aholo-docs/SKILL.md new file mode 100644 index 0000000..61c3535 --- /dev/null +++ b/.codex/skills/aholo-docs/SKILL.md @@ -0,0 +1,38 @@ +--- +name: aholo-docs +description: Work on Aholo Viewer documentation and product writing. Use for README, AGENTS.md, docs/architecture.md, docs/ai/vibe-coding-guide.md, manual pages, API documentation flow, bilingual copy, release-readiness notes, and AI collaboration guidance. +--- + +# Aholo Docs + +Use `docs/ai/vibe-coding-guide.md` as the writing source of truth. + +## Core Rules + +- Product: high-performance 3DGS rendering for web applications. +- Audience: frontend engineers, SDK docs readers, product-demo evaluators. +- Chinese copy: technical and concise. +- English copy: SDK documentation style. +- Keep zh-CN and en-US manual pages structurally parallel. +- Treat `website/src/content/manual/` as filesystem Markdown content, not an Astro content collection. +- Keep manual assets under `website/src/content/manual/assets/` and use local relative image references in manual Markdown. +- Use real public renderer exports in docs and examples. +- Do not document internal renderer symbols without user direction. + +## Key Files + +- `AGENTS.md` +- `README.md` +- `docs/architecture.md` +- `docs/ai/vibe-coding-guide.md` +- `website/src/content/manual/{zh-CN,en-US}/` +- `website/src/utils/manual.ts` +- `website/src/utils/manual-assets.js` + +## Validate + +Docs-only notes need path/reference scans. Manual or website-imported docs need: + +```bash +pnpm.cmd check:website +``` diff --git a/.codex/skills/aholo-docs/agents/openai.yaml b/.codex/skills/aholo-docs/agents/openai.yaml new file mode 100644 index 0000000..9439616 --- /dev/null +++ b/.codex/skills/aholo-docs/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Aholo Docs" + short_description: "Docs, copy, manuals, and handoffs." + default_prompt: "Use $aholo-docs to update Aholo documentation, bilingual copy, or AI collaboration guidance." diff --git a/.codex/skills/aholo-examples/SKILL.md b/.codex/skills/aholo-examples/SKILL.md new file mode 100644 index 0000000..a0b0baf --- /dev/null +++ b/.codex/skills/aholo-examples/SKILL.md @@ -0,0 +1,46 @@ +--- +name: aholo-examples +description: Work on Aholo Viewer example content pairs under website/src/content/examples. Use for creating, modifying, reviewing, or validating example *.json metadata and *.ts runners, Playground presets derived from examples, home-only example runners, config panels, async loading/caching, cleanup, and source shown in Playground. +--- + +# Aholo Examples + +Own example content. + +## Owns + +- `website/src/content/examples/.json` +- `website/src/content/examples/.ts` +- Content metadata, `RenderRuntime` runner lifecycle, async loading, IndexedDB caching, config panels, and cleanup. +- Source code that appears as a Playground preset. + +## References + +- Use `splatting-basic`, `splatting-lod-stream`, `3d-buffer-geometry`, `3d-point-light`, and `home-interaction` as normal references. +- Read `website/src/content.config.ts` for examples metadata schema. +- Read `website/src/client/render-runtime.d.ts` for runner runtime. +- Read `website/src/utils/examples.ts` when changing surfaces, ordering, presets, or default examples. + +## Contracts + +- Keep JSON and TS files paired by the same slug. +- Use `surfaces` only when deviating from the default `["examples", "playground"]`; use `["home"]` for home-only runners. Do not repeat surface names. +- Keep `order` nonnegative and integral, `tags` nonempty, and `accent` as a six-digit hex color. +- Keep Chinese titles technical and concise; keep English titles in SDK documentation style. +- Import renderer APIs from `@manycore/aholo-viewer` and use actual public exports. +- Export an async default runner that accepts `RenderRuntime`. +- Check `signal.aborted` after async work and throw `AbortError` with a specific message. +- Use `loading.show()` before async fetch/decode and `loading.hide()` only after the scene is ready. +- Return a cleanup function that removes scene objects and destroys created GPU resources. + +## Boundaries + +- Do not change renderer public exports. Surface the need instead. +- Do not edit `packages/renderer/dist/` or `website/.generated/api/`. +- Use `aholo-site` for examples page chrome, Playground shell, client render runtime, or style work. + +## Validate + +- Metadata-only: `pnpm.cmd check:content`. +- Runner or website integration: `pnpm.cmd check:website`. +- Renderer API/package interaction: `pnpm.cmd check`. diff --git a/.codex/skills/aholo-examples/agents/openai.yaml b/.codex/skills/aholo-examples/agents/openai.yaml new file mode 100644 index 0000000..bc0b4ce --- /dev/null +++ b/.codex/skills/aholo-examples/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Aholo Examples" + short_description: "Create and maintain Aholo Viewer examples." + default_prompt: "Use $aholo-examples to create, modify, or validate Aholo Viewer website examples." diff --git a/.codex/skills/aholo-renderer/SKILL.md b/.codex/skills/aholo-renderer/SKILL.md new file mode 100644 index 0000000..48c0a39 --- /dev/null +++ b/.codex/skills/aholo-renderer/SKILL.md @@ -0,0 +1,24 @@ +--- +name: aholo-renderer +description: Work on the @manycore/aholo-viewer package. Use only when the user explicitly asks for renderer source, package build, public exports, JSDoc/API docs, generated declaration flow, renderer dist, packing, or release validation. +--- + +# Aholo Renderer + +Work inside `packages/renderer/` and renderer-related scripts. + +## Core Rules + +- Renderer public API exports are user-owned. +- Do not change `packages/renderer/src/index.ts` exports unless explicitly asked. +- Do not hand-edit `external/egs-core` or `packages/renderer/dist/`. +- Do not delete `external/splat-transform`; it is a required workspace package. +- Add concise JSDoc only for user-approved public API work. +- If public API changes, regenerate docs through normal commands. + +## Validate + +```bash +pnpm.cmd check:renderer +pnpm.cmd build:renderer +``` diff --git a/.codex/skills/aholo-renderer/agents/openai.yaml b/.codex/skills/aholo-renderer/agents/openai.yaml new file mode 100644 index 0000000..7029165 --- /dev/null +++ b/.codex/skills/aholo-renderer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Aholo Renderer" + short_description: "Renderer package, API, and release flow." + default_prompt: "Use $aholo-renderer to work on the renderer package, public API, generated declarations, or release validation." diff --git a/.codex/skills/aholo-site/SKILL.md b/.codex/skills/aholo-site/SKILL.md new file mode 100644 index 0000000..870ba82 --- /dev/null +++ b/.codex/skills/aholo-site/SKILL.md @@ -0,0 +1,41 @@ +--- +name: aholo-site +description: Work on Aholo Viewer Astro website and Playground surfaces under website/. Use for routes, layouts, components, page chrome, i18n UI copy, scoped CSS, client render runtime, Playground runner, preview canvas, inspector, presets, route-local Monaco, and URL parameters. Use aholo-examples for example content pairs. +--- + +# Aholo Site + +Own the website shell and Playground integration. Do not own renderer source or example content pairs. + +## Owns + +- Astro routes, layouts, flat feature components, utilities, i18n, and site content under `website/`. +- Examples page chrome and preview layout, not `website/src/content/examples/*.json` or `*.ts`. +- Client render runtime under `website/src/client/`, camera/control integration, inspector, presets, and URL state. +- Playground shell in `website/src/components/PlaygroundShell.astro` and browser entry in `website/src/client/playground.ts`. +- Scoped styles: `theme.css`, `global.css`, `site.css`, `home.css`, `examples.css`, `docs.css`, `playground.css`. + +## Boundaries + +- Keep Monaco route-local through `website/src/components/PlaygroundShell.astro`; keep Playground browser entry in `website/src/client/playground.ts`. +- Preserve Playground URL params: `example` and `code`. +- Keep render runtime implementation in `website/src/client/render-runtime.ts` and runner contract in `website/src/client/render-runtime.d.ts`. +- Keep renderer type hints sourced from `packages/renderer/dist/index.d.ts`. +- Keep `website/src/content.config.ts` focused on Astro collections; manual pages are loaded through `website/src/utils/manual.ts`. +- Keep feature selectors out of `global.css`. +- Do not modify renderer public exports. +- Do not hand-edit `website/.generated/api/`. +- Use `aholo-examples` for example JSON/TS source pairs. + +## Design + +- Keep the site simple, refined, spacious, and tool-like. +- Keep homepage first-screen interactive renderer true fullscreen and resize the renderer after entering or exiting. + +## Validate + +```bash +pnpm.cmd check:website +``` + +Use `pnpm.cmd check` when renderer declarations or package output are involved. diff --git a/.codex/skills/aholo-site/agents/openai.yaml b/.codex/skills/aholo-site/agents/openai.yaml new file mode 100644 index 0000000..be6d047 --- /dev/null +++ b/.codex/skills/aholo-site/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Aholo Site" + short_description: "Astro website, docs UI, examples, and Playground." + default_prompt: "Use $aholo-site to update Aholo Viewer website routes, styles, examples, docs UI, or Playground behavior." diff --git a/.codex/skills/aholo-viewer/SKILL.md b/.codex/skills/aholo-viewer/SKILL.md new file mode 100644 index 0000000..e3c4240 --- /dev/null +++ b/.codex/skills/aholo-viewer/SKILL.md @@ -0,0 +1,35 @@ +--- +name: aholo-viewer +description: Coordinate Aholo Viewer monorepo work. Use for cross-module tasks, workspace scripts, generated docs/build flow, validation routing, cleanup boundaries, or when work must be routed between examples, site, renderer, docs, and frontend design skills. +--- + +# Aholo Viewer + +Read `AGENTS.md` first. Use this skill only when the task spans modules or needs repo-level validation/routing. + +## Route + +- `aholo-examples`: example content pairs under `website/src/content/examples/`, including `walk-demo`. +- `aholo-site`: Astro website shell, examples page chrome, docs UI, Playground, Monaco, client render runtime. +- `aholo-renderer`: renderer package, public API, declarations, build, dist, release checks. +- `aholo-docs`: README, AGENTS, architecture notes, manual copy/assets, AI guides, release notes. +- `frontend-design`: website visual direction, responsive polish, UI review. + +## Boundaries + +- Keep website work in `website/`, renderer work in `packages/renderer/`, shared automation in `scripts/`. +- Keep examples as paired metadata/runners and manual as filesystem Markdown loaded through website utilities. +- Do not hand-edit `external/egs-core`, `website/.generated/api/`, or generated `dist/`. +- Do not delete `external/splat-transform`. +- Do not change renderer public exports unless explicitly asked. + +## Validate + +```bash +pnpm.cmd check:website +pnpm.cmd check:renderer +pnpm.cmd check +pnpm.cmd build +``` + +If esbuild reports `Cannot read directory "../../../.."`, rerun the same command with approved workspace access. diff --git a/.codex/skills/aholo-viewer/agents/openai.yaml b/.codex/skills/aholo-viewer/agents/openai.yaml new file mode 100644 index 0000000..6f87590 --- /dev/null +++ b/.codex/skills/aholo-viewer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Aholo Viewer" + short_description: "Route repo-level Aholo work and validation." + default_prompt: "Use $aholo-viewer to route Aholo monorepo tasks, inspect workspace boundaries, and choose validation commands." diff --git a/.codex/skills/frontend-design/LICENSE.txt b/.codex/skills/frontend-design/LICENSE.txt new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/.codex/skills/frontend-design/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.codex/skills/frontend-design/SKILL.md b/.codex/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..81a27f5 --- /dev/null +++ b/.codex/skills/frontend-design/SKILL.md @@ -0,0 +1,30 @@ +--- +name: frontend-design +description: Design and polish Aholo Viewer frontend interfaces with production-grade visual quality. Use for website UI direction, Astro page/component styling, responsive layout, visual refinement, and frontend design reviews in this repository. +--- + +# Frontend Design + +Use this local skill for Aholo Viewer website and Playground visual design. Keep implementation consistent with the existing Astro site and scoped CSS architecture. + +## Direction + +- Audience: frontend engineers, SDK docs readers, and product-demo evaluators. +- Product: high-performance 3DGS rendering for web applications. +- Tone: simple, refined, spacious, comfortable, technical, and tool-like. +- Avoid generic AI aesthetics, broad marketing visuals, and overdecorated layouts. + +## Rules + +- Respect existing style layers: `theme.css`, `global.css`, `site.css`, `home.css`, `examples.css`, `docs.css`, `playground.css`. +- Keep feature-specific selectors out of `global.css`. +- Use restrained color, typography, spacing, and motion that fit SDK documentation and technical tooling. +- Do not make marketing-style landing sections unless the user explicitly asks. +- Ensure text, controls, canvas surfaces, and panels do not overlap on mobile or desktop. +- Use icons for compact controls when available, and keep repeated tool UI stable in size. + +## Validate + +```bash +pnpm.cmd check:website +``` diff --git a/.codex/skills/frontend-design/agents/openai.yaml b/.codex/skills/frontend-design/agents/openai.yaml new file mode 100644 index 0000000..e0bcb27 --- /dev/null +++ b/.codex/skills/frontend-design/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Frontend Design" + short_description: "Aholo website visual direction and UI polish." + default_prompt: "Use $frontend-design to refine Aholo Viewer frontend styling, layout, responsive behavior, or visual design quality." diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f026e5f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# EditorConfig is awesome: https://EditorConfig.org +root = true + +[*] +end_of_line = lf +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{json,yaml,yml,astro}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1e559c2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# FORCE LF ON ANY PLATFORM +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5445094 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,57 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Package version (please fill the releated package info)** + +- @manycore/aholo-viewer: [e.g. 1.0.0] + +**Desktop (please complete the following information, optional, only if you found the issue):** + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] + +**Smartphone (please complete the following information, optional, only if you found the issue):** + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] + +**Viewer Config (If possible, please complete the content, the config you used when call `setViewConfig`)** + +```typescript +{ + // fill the config here. +} +``` + +**Check List** + +- [ ] Can you reproduce the issue on https://aholojs.dev/en-US/viewer/? +- [ ] Can you reproduce the issue on the latest version? + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..96a4735 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,7 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' +--- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..2f28cea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/build-native.yaml b/.github/workflows/build-native.yaml new file mode 100644 index 0000000..1cca0aa --- /dev/null +++ b/.github/workflows/build-native.yaml @@ -0,0 +1,144 @@ +on: + push: + branches: + - 'master' + paths: + - 'packages/splat-transform-native/source/**' + pull_request: + branches: + - 'master' + paths: + - 'packages/splat-transform-native/source/**' + workflow_dispatch: + +name: build native + +env: + NODE_VERSION: 24 + +jobs: + build-splat-transform-native: + name: Build splat-transform native (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: win32-x64-msvc + native-script: build:native:win32-x64 + os: windows-2025-vs2026 + shell: pwsh + - target: win32-arm64-msvc + native-script: build:native:win32-arm64 + os: windows-11-vs2026-arm + shell: pwsh + - target: linux-x64-gnu + native-script: build:native:linux-x64-gnu + os: ubuntu-22.04 + shell: bash + - target: linux-arm64-gnu + native-script: build:native:linux-arm64-gnu + os: ubuntu-22.04-arm + shell: bash + - target: darwin-arm64 + native-script: build:native:darwin-arm64 + os: macos-26 + shell: bash + defaults: + run: + shell: ${{ matrix.shell }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + submodules: recursive + lfs: true + + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install workspace dependencies + run: | + corepack enable + corepack install + pnpm install + + - name: Setup env (Linux) + if: startsWith(matrix.os, 'ubuntu') + run: | + wget https://apt.llvm.org/llvm.sh + chmod a+x ./llvm.sh + sudo ./llvm.sh 21 + sudo apt-get update + sudo apt-get install -y clang-21 clang-tools-21 lld-21 ninja-build pkg-config nasm + sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-21 20 + sudo update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-21 20 + sudo update-alternatives --install /usr/bin/cc cc /usr/bin/clang-21 20 + sudo update-alternatives --install /usr/bin/c++ c++ /usr/bin/clang++-21 20 + sudo update-alternatives --set clang /usr/bin/clang-21 + sudo update-alternatives --set clang++ /usr/bin/clang++-21 + sudo update-alternatives --set cc /usr/bin/clang-21 + sudo update-alternatives --set c++ /usr/bin/clang++-21 + cmake --version + cc --version + c++ --version + ninja --version + + - name: Setup env (macOS) + if: startsWith(matrix.os, 'macos') + run: | + brew install ninja nasm + cmake --version + clang --version + ninja --version + + - name: Setup vcpkg (Windows) + if: startsWith(matrix.os, 'windows') + run: | + if ($env:VCPKG_INSTALLATION_ROOT -and (Test-Path $env:VCPKG_INSTALLATION_ROOT)) { + $vcpkgRoot = $env:VCPKG_INSTALLATION_ROOT + } else { + $vcpkgRoot = Join-Path $env:RUNNER_TEMP "vcpkg" + git clone https://github.com/microsoft/vcpkg $vcpkgRoot + & (Join-Path $vcpkgRoot "bootstrap-vcpkg.bat") -disableMetrics + } + "VCPKG_ROOT=$vcpkgRoot" >> $env:GITHUB_ENV + + - name: Setup vcpkg (Unix) + if: ${{ !startsWith(matrix.os, 'windows') }} + run: | + if [ -n "${VCPKG_INSTALLATION_ROOT:-}" ] && [ -d "${VCPKG_INSTALLATION_ROOT}" ]; then + vcpkg_root="${VCPKG_INSTALLATION_ROOT}" + else + vcpkg_root="${RUNNER_TEMP}/vcpkg" + git clone https://github.com/microsoft/vcpkg "${vcpkg_root}" + "${vcpkg_root}/bootstrap-vcpkg.sh" -disableMetrics + fi + echo "VCPKG_ROOT=${vcpkg_root}" >> "${GITHUB_ENV}" + + - name: Build splat-transform native + working-directory: packages/splat-transform-native/source + run: pnpm run ${{ matrix.native-script }} + + - name: Validate native package (Windows) + if: startsWith(matrix.os, 'windows') + run: | + if (!(Test-Path ./packages/splat-transform-native/splat-transform-${{ matrix.target }}/splat-transform.node)) { + throw "Missing splat-transform.node" + } + node -e "require('./packages/splat-transform-native/splat-transform-${{ matrix.target }}/splat-transform.node'); console.log('native require ok')" + + - name: Validate native package (Unix) + if: ${{ !startsWith(matrix.os, 'windows') }} + run: | + test -f ./packages/splat-transform-native/splat-transform-${{ matrix.target }}/splat-transform.node + node -e "require('./packages/splat-transform-native/splat-transform-${{ matrix.target }}/splat-transform.node'); console.log('native require ok')" + + - name: Upload native package + uses: actions/upload-artifact@v7 + with: + name: splat-transform-${{ matrix.target }} + path: packages/splat-transform-native/splat-transform-${{ matrix.target }}/splat-transform.node + if-no-files-found: error diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..ee3fcf0 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,26 @@ +on: + push: + branches: + - 'master' + pull_request: + branches: + - 'master' + +name: check + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + submodules: recursive + lfs: true + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: 24 + - run: | + chmod a+x ./ci/check.sh + ./ci/check.sh diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..481c2ea --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,123 @@ +on: workflow_dispatch + +name: publish + +env: + NODE_VERSION: 24 + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + environment: + name: publish + deployment: false + if: github.ref == 'refs/heads/master' + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + submodules: recursive + lfs: true + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + - name: Check splat-transform publish status + id: splat-transform + run: | + package_name="$(node -p "require('./packages/splat-transform/package.json').name")" + package_version="$(node -p "require('./packages/splat-transform/package.json').version")" + if npm view "${package_name}@${package_version}" version --registry=https://registry.npmjs.org --silent >/dev/null 2>&1; then + echo "${package_name}@${package_version} already exists on npm." + echo "needs_publish=false" >> "$GITHUB_OUTPUT" + else + echo "${package_name}@${package_version} is not published yet." + echo "needs_publish=true" >> "$GITHUB_OUTPUT" + fi + - name: Resolve latest native build + if: steps.splat-transform.outputs.needs_publish == 'true' + id: native-build + env: + GH_TOKEN: ${{ github.token }} + run: | + run_id="$(gh api --method GET "/repos/${{ github.repository }}/actions/workflows/build-native.yaml/runs" -f branch=master -f status=success -f per_page=1 --jq '.workflow_runs[0].id // empty')" + if [ -z "$run_id" ]; then + echo "No successful build-native workflow run found on master." >&2 + exit 1 + fi + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + - name: Download native packages + if: steps.splat-transform.outputs.needs_publish == 'true' + uses: actions/download-artifact@v7 + with: + pattern: splat-transform-* + path: packages/splat-transform-native + merge-multiple: false + repository: ${{ github.repository }} + run-id: ${{ steps.native-build.outputs.run_id }} + github-token: ${{ github.token }} + - name: Publish packages + id: publish-packages + env: + NPM_TOKEN: ${{secrets.NPM_TOKEN}} + run: | + set -o pipefail + publish_log="$(mktemp)" + chmod a+x ./ci/publish.sh + ./ci/publish.sh | tee "$publish_log" + if grep -q '^published:$' "$publish_log"; then + echo "has_published=true" >> "$GITHUB_OUTPUT" + else + echo "has_published=false" >> "$GITHUB_OUTPUT" + fi + - name: Create GitHub releases + if: steps.publish-packages.outputs.has_published == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + releases=( + "packages/splat-transform|splat-transform|false" + "packages/renderer|aholo-viewer|true" + ) + + for release in "${releases[@]}"; do + IFS='|' read -r package_dir tag_prefix make_latest <<< "$release" + package_name="$(node -p "require('./${package_dir}/package.json').name")" + package_version="$(node -p "require('./${package_dir}/package.json').version")" + tag_name="${tag_prefix}@${package_version}" + release_name="${tag_prefix} ${package_version}" + + if gh release view "$tag_name" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "GitHub release ${tag_name} already exists." + continue + fi + + echo "Creating GitHub release ${tag_name} for ${package_name}@${package_version}." + release_notes_file="$(mktemp)" + node scripts/read-package-changelog.mjs "$package_name" "$package_version" > "$release_notes_file" + + if git ls-remote --exit-code --tags origin "refs/tags/${tag_name}" >/dev/null 2>&1; then + git fetch --force --depth=1 origin "refs/tags/${tag_name}:refs/tags/${tag_name}" + if ! git cat-file -e "${tag_name}^{tag}" 2>/dev/null; then + echo "Tag ${tag_name} already exists but is not an annotated tag." >&2 + exit 1 + fi + echo "Annotated tag ${tag_name} already exists." + else + git tag -a "$tag_name" "$GITHUB_SHA" -m "$tag_name" + git push origin "refs/tags/${tag_name}" + fi + + gh api --method POST "/repos/${{ github.repository }}/releases" \ + -f tag_name="$tag_name" \ + -f target_commitish="$GITHUB_SHA" \ + -f name="$release_name" \ + -F body=@"$release_notes_file" \ + -f make_latest="$make_latest" >/dev/null + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ab606a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +.pnpm-store/ +dist/ +build/ +.astro/ +website/.generated/ +.typedoc-*.tmp*/ +.typedoc-*.tmp.json +*.log +.env +.env.* +!.env.example diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..272bad5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "external/egs-core"] + path = external/egs-core + url = https://github.com/manycoretech/egs.git + branch = master diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..1ecebb9 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +pnpm lint-staged -q; +git update-index --again; diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..b628bc0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "oxc.oxc-vscode", + "editorconfig.editorconfig", + "streetsidesoftware.code-spell-checker" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..28103d0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,25 @@ +{ + "js/ts.tsdk.path": "node_modules\\typescript\\lib", + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode", + }, + "[javascript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[yaml]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "cSpell.words": [ + "aholo", + "gltf", + "manycore", + "Raycaster", + "oxc", + "oxlint", + "oxfmt" + ], + "C_Cpp.default.cppStandard": "c++20" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cf3e82c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# Agent Guide + +AI collaboration entrypoint. Keep context small, preserve user changes, and route work to the narrowest project skill. + +## Modules + +- Root: `pnpm` workspace orchestration, validation, release scripts. +- Website: `website/` Astro routes, layouts, flat feature components, client render runtime, docs pages, Playground, scoped styles. +- Examples: `website/src/content/examples/` paired example metadata and `RenderRuntime` runners. +- Renderer: `packages/renderer/` source and generated package output. +- Scripts: `scripts/` submodule prep, renderer build/clean, EGS types, API docs. +- Docs: `README.md`, `docs/`, manual pages, manual assets, AI collaboration guidance. +- External: `external/egs-core` submodule and `external/splat-transform` workspace package. +- Skills: `.codex/skills/` project-local collaboration rules. + +## Skill Routing + +- `aholo-examples`: example `.json` and `.ts` pairs, including `walk-demo`, metadata schema, runner lifecycle. +- `aholo-site`: website shell, page chrome, client render runtime, Playground integration, route-local Monaco, scoped CSS. +- `aholo-renderer`: renderer source, public API, declarations, generated dist flow, package/release validation. +- `aholo-docs`: README, AGENTS, architecture notes, AI guides, manual copy, bilingual docs. +- `frontend-design`: visual direction, responsive UI polish, layout/design review. +- `aholo-viewer`: only for cross-module work, scripts, generated docs/build flow, cleanup boundaries, or validation routing. + +## Hard Boundaries + +- Do not overwrite user changes. Start with `git status --short`. +- Keep edits inside the owning module unless the task explicitly spans modules. +- Do not hand-edit `external/egs-core`, `website/.generated/api/`, or `packages/renderer/dist/`. +- Do not delete `external/splat-transform`. +- Do not change `packages/renderer/src/index.ts` exports unless asked. +- Do not compare the renderer to third-party engines or frameworks unless asked. + +## Website Boundaries + +- Keep Monaco route-local through `website/src/components/PlaygroundShell.astro`; keep Playground browser entry in `website/src/client/playground.ts`. +- Preserve Playground URL params: `example` and `code`. +- Keep render runtime implementation in `website/src/client/render-runtime.ts` and runner contract in `website/src/client/render-runtime.d.ts`. +- Keep examples paired as `website/src/content/examples/.json` and `.ts`. +- Keep `website/src/content.config.ts` focused on Astro content collections; manual pages use `website/src/utils/manual.ts`. +- Keep top-level feature components flat in `website/src/components/`; keep only shared groups such as `docs/` and `site/` nested. +- Keep style ownership clear: `theme.css`, `global.css`, `site.css`, `home.css`, `examples.css`, `docs.css`, `playground.css`. +- Keep feature selectors out of `global.css`. +- Homepage first-screen interactive stage must remain true fullscreen: fixed positioning, `inset: 0`, `100dvh`/`100vw`, and renderer resize after state changes. + +## Collaboration + +- Reply to the user in Chinese. +- Use PowerShell with `pnpm.cmd` if `pnpm` or `pnpm.ps1` fails. +- If port `4321` is already in use, assume the user's dev server is running and use it directly. +- Read `docs/architecture.md` only for package boundaries, generated API docs, build flow, or directory structure. +- Keep Chinese copy technical and concise. Keep English copy in SDK documentation style. +- For manual pages and UI translations, treat Chinese wording as the source of truth. +- Use actual public renderer exports in docs and examples. +- End handoffs with changed files, validation, skipped checks, and risks. + +## Validation + +- Website/layout/manual/example runtime: `pnpm.cmd check:website`. +- Content-only example metadata: `pnpm.cmd check:content`. +- Renderer/API/release work: `pnpm.cmd check`. +- Package/release changes: `pnpm.cmd build`. +- Docs-only repo notes: path/reference scan is enough unless imported by the site. +- If esbuild reports `Cannot read directory "../../../.."`, rerun the same command with approved workspace access. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8d6424f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright © 2026 MANYCORE, INC. + +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..d6d47a2 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +[![logo](./website/public/aholo-logo.svg)](https://aholojs.dev/) + +# Aholo Viewer + +![Build Status](https://github.com/manycoretech/aholo-viewer/actions/workflows/check.yaml/badge.svg?branch=master) +[![aholo-viewer](https://img.shields.io/npm/v/@manycore/aholo-viewer?label=aholo-viewer)](https://www.npmjs.com/package/@manycore/aholo-viewer) +[![splat-transform](https://img.shields.io/npm/v/@manycore/aholo-splat-transform?label=splat-transform)](https://www.npmjs.com/package/@manycore/aholo-splat-transform) +[![license](https://img.shields.io/npm/l/@manycore/aholo-viewer)](#license) + +Monorepo for the Aholo Viewer package and its documentation website. + +## What is Aholo Viewer + +Aholo Viewer is a high performance Renderer for 3DGS and Mesh. It uses `Chunked Steaming Lod` schema to handle huge 3DGS. + +## Usage + +Follow the [Manual](https://aholojs.dev/en-US/manual/getting-started/). + +> If everything goes well you will see [this](https://jsfiddle.net/hs6Lp038/) + +For npm package usage, see [`packages/renderer/README.md`](./packages/renderer/README.md). + +For AI-assisted integration, use [`docs/ai/skills/use-aholo-viewer/SKILL.md`](./docs/ai/skills/use-aholo-viewer/SKILL.md). + +## Build Requirements + +- Node: >= 22.22.1 +- pnpm(corepack preferred) + +## Clone this repository + +This repository contains some submodules as dependencies, use following command to clone the repository. + +```bash +git clone --recurse-submodules https://github.com/manycoretech/aholo-viewer.git +``` + +## Structure + +```text +aholo-viewer/ + AGENTS.md Agent-facing project guide + website/ Astro website: home, manual, examples, API docs, playground + packages/renderer/ Renderer TypeScript source package + scripts/ Shared build and documentation scripts + docs/ Architecture and AI collaboration notes + external/ Required upstream and workspace dependency sources + .codex/skills/ Project-local Codex skills +``` + +## Commands + +Run workspace commands from the repository root: + +```bash +pnpm install +pnpm dev +pnpm check +pnpm build +pnpm preview +``` + +Targeted root commands: + +```bash +pnpm build:renderer +pnpm build:website +pnpm check:content +pnpm check:renderer +pnpm check:website +pnpm docs:api +``` + +## Project Docs + +- `AGENTS.md`: quick guide for AI agents and future coding sessions +- `docs/architecture.md`: current workspace structure and dependency flow +- `docs/ai/vibe-coding-guide.md`: detailed guide for future AI-assisted changes, writing style, and handoffs +- `docs/ai/skills/use-aholo-viewer/SKILL.md`: external npm package usage guide for AI coding agents +- `.codex/skills/`: local Codex skills split by repo area + +## Codex Skills + +Project-local skills live in `.codex/skills/`. Use `AGENTS.md` for the current skill map. + +## External Source + +`external/egs-core` is a required upstream submodule. `external/splat-transform` is a required workspace package and must stay in the repo. Treat upstream code under `external/` as read-only unless a task explicitly targets that package. + +## API Docs + +API docs are generated from `packages/renderer/src/index.ts` into an ignored local directory: + +```text +website/.generated/api/ +``` + +`pnpm dev`, `pnpm build`, and `pnpm check` regenerate them automatically. Run the generator directly when you want to refresh the local TypeDoc HTML and manifest without starting the site: + +```bash +pnpm docs:api +``` + +## Content Checks + +`pnpm check:content` validates manual locale parity, empty pages, example source pairs, manual image references, orphan manual images, and internal-only documentation links. It is also part of `pnpm check` and `pnpm check:website`. + +## Playground URLs + +The Playground keeps edited code in the URL with `lz-string`: + +```text +/zh-CN/playground/?example=basic-scene&code= +``` + +Opening a URL with `code` restores the editor content automatically. + +Examples are stored in `website/src/content/examples/` as paired `.json` metadata and `.ts` source files. The same slug powers the Examples pages and Playground `example` query parameter. + +## Contributors + + + + + +Made with [contrib.rocks](https://contrib.rocks). + +## Star History + + + + + + Star History Chart + + + +## Useful Links + +- [Discussions](https://github.com/manycoretech/aholo-viewer/discussions) +- [Official website](https://aholojs.dev/) +- [CHANGELOG](./packages/renderer/CHANGELOG.md) + +## License + +[MIT License](./LICENSE). diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..ec4f3b2 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`manycoretech/aholo-viewer` +- 原始仓库:https://github.com/manycoretech/aholo-viewer +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/ci/check.sh b/ci/check.sh new file mode 100644 index 0000000..d4b3d50 --- /dev/null +++ b/ci/check.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -e +npm install npm -g +npm install corepack -g +corepack enable +corepack install + +pnpm install +pnpm --filter @manycore/aholo-splat-transform run build +pnpm check diff --git a/ci/publish.sh b/ci/publish.sh new file mode 100644 index 0000000..dd164b1 --- /dev/null +++ b/ci/publish.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -e +npm install npm -g +npm install corepack -g +corepack enable +corepack install + +pnpm install +npm config set registry https://registry.npmjs.org +npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} +node scripts/publish-packages.mjs diff --git a/docs/ai/skills/use-aholo-viewer/SKILL.md b/docs/ai/skills/use-aholo-viewer/SKILL.md new file mode 100644 index 0000000..48fc2fd --- /dev/null +++ b/docs/ai/skills/use-aholo-viewer/SKILL.md @@ -0,0 +1,169 @@ +--- +name: use-aholo-viewer +description: Guide external npm package users and AI coding agents integrating @manycore/aholo-viewer into web applications. Use when asked to install, bootstrap, render 3D Gaussian Splatting or mesh content, configure cameras, scenes, lighting, viewer pipeline options, or write TypeScript examples for the public @manycore/aholo-viewer npm package. +--- + +# Use Aholo Viewer + +Use this skill to build or modify user applications that consume the public `@manycore/aholo-viewer` npm package. Treat it as a full-featured browser rendering package, not as this repository's internal renderer source. + +## Source Docs + +Before generating non-trivial code, read the AI-oriented docs advertised by `https://aholojs.dev/llms.txt`: + +- `https://aholojs.dev/llm/manual/getting-started.md` for installation and the minimal Vite setup. +- `https://aholojs.dev/llm/manual/basic-concepts.md` for scenes, objects, cameras, viewports, and the render flow. +- `https://aholojs.dev/llm/api/index.md` to confirm public exports. +- `https://aholojs.dev/llm/examples/index.md` for runnable TypeScript patterns. + +Prefer these Markdown docs over scraping rendered HTML pages. + +## Integration Workflow + +1. Install the package in a browser app: + + ```bash + npm install --save @manycore/aholo-viewer + npm install --save-dev vite typescript + ``` + +2. Import only public package exports from `@manycore/aholo-viewer`. + +3. Create a real DOM container with stable dimensions before calling `createViewer`. Attach application DOM event listeners to this container, not to the engine canvas. + +4. Create a camera, position it, call `lookAt`, add objects to `viewer.getScene()`, then call `viewer.setCamera(camera)`. + +5. Configure rendering with `setViewerConfig(viewer, { pipeline: ... })`. + +6. Render explicitly with `viewer.render()`. If the app relies on viewer-triggered invalidation, set `viewer.requestRenderHandler` to schedule another render with `requestAnimationFrame`. + +7. Clean up when removing content: detach scene objects, stop render/update loops that still reference them, then release GPU resources or owned resources according to the cleanup rules below. + +## Minimal Splat Example + +Use this pattern for a Vite/TypeScript browser entry: + +```ts +import { + BackgroundMode, + Color, + PerspectiveCamera, + SplatLoader, + SplatUtils, + Vector3, + createViewer, + setViewerConfig, +} from '@manycore/aholo-viewer'; + +const SPLAT_URL = 'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/bear/bear.3d71a266.sog'; + +const container = document.createElement('div'); +container.style.width = '500px'; +container.style.height = '500px'; +document.body.appendChild(container); + +async function main() { + const viewer = createViewer('example-viewer', container, {}); + const camera = new PerspectiveCamera(60, 1, 0.1, 2000); + + const response = await fetch(SPLAT_URL); + const buffer = await response.arrayBuffer(); + const data = await SplatLoader.parseSplatData( + SplatLoader.SplatFileType.SOG, + new Uint8Array(buffer), + SplatLoader.SplatPackType.Compressed, + ); + const splat = await SplatUtils.createSplat(data); + + camera.up.set(0, -1, 0); + camera.position.set(-1.5, -0.5, 0); + camera.lookAt(new Vector3(0, 0, 0)); + + viewer.getScene().add(splat); + viewer.setCamera(camera); + setViewerConfig(viewer, { + pipeline: { + Background: { + background: { + active: BackgroundMode.BasicBackground, + basic: { color: new Color(0, 0, 0) }, + }, + ground: { enabled: false }, + }, + Splatting: { enabled: true }, + TAA: { enabled: false }, + }, + }); + + const render = () => viewer.render(); + viewer.requestRenderHandler = () => requestAnimationFrame(render); + requestAnimationFrame(render); +} + +main(); +``` + +## Common Public Exports + +- Viewer setup: `createViewer`, `setViewerConfig`, `Viewer`, `Viewport`. +- Cameras and math: `PerspectiveCamera`, `OrthographicCamera`, `Vector2`, `Vector3`, `Vector4`, `Color`, `Matrix4`, `Quaternion`. +- Scene objects: `Object3D`, `Scene3D`, `Mesh`, `Splat`, `AmbientLight`, `DirectionalLight`, `PointLight`, `SpotLight`. +- Mesh content: `BufferGeometry`, `BufferAttribute`, `MeshBasicMaterial`, `MeshPhongMaterial`, `Side`, `downloadTexture`. +- Splat content: `SplatLoader`, `SplatUtils`, `CompressedSplat`, `SuperCompressedSplat`, `SogSplat`. +- Optional loaders/utilities: `GLTFLoader`, `DracoLoader`, `Animation`, `Events`. + +Confirm exact symbols in `/llm/api/index.md` when using less common APIs. + +## Patterns + +For 3DGS content: + +- Use `SplatLoader.parseSplatData(...)` to parse supported splat formats. +- Use `SplatUtils.createSplat(data)` to create a renderable splat. +- Enable `pipeline.Splatting`. +- Set camera up vectors deliberately. OpenCV-style 3DGS data commonly uses `camera.up.set(0, -1, 0)`. + +For mesh content: + +- Build geometry with `BufferGeometry`, `BufferAttribute`, and optional indices. +- Use `MeshPhongMaterial` with lights for shaded meshes, or `MeshBasicMaterial` for unlit objects. +- Add lights such as `AmbientLight`, `DirectionalLight`, or `PointLight` before rendering lit materials. + +For interactive apps: + +- Tie camera controls or animations to a frame loop that calls `viewer.render()`. +- Keep viewer container size stable and update rendering after layout changes. +- Register pointer, wheel, keyboard focus, drag/drop, resize, and custom DOM event listeners on the viewer container or another application-owned element. +- Cache large splat data when appropriate, but keep the first example simple unless the user asks for caching. + +## DOM Events and Canvas Access + +Treat the container as the stable application boundary: + +- Listen for DOM events on the `container` passed to `createViewer(...)`, or on another element owned by the application. +- Do not attach event listeners to the viewer canvas. The canvas is managed internally by the engine and may be replaced. +- Do not persistently store the engine canvas element in application state, class fields, module globals, framework refs, or closures intended to outlive the current operation. +- If direct canvas access is unavoidable, read it just-in-time from the current viewer/container state, use it immediately, and discard the reference. +- Remove container-level event listeners during teardown before releasing viewer or scene resources. + +## Resource Cleanup + +Use the lightest cleanup method that matches ownership and lifecycle: + +- Use `freeGPU()` when the goal is to release GPU memory while keeping the object instance logically reusable. The object may upload GPU resources again if it is rendered or updated later. +- Use `freeAllGpuResourceOwned()` when the object owns related GPU resources that should also be released. For example, `geometry.freeAllGpuResourceOwned()` releases the geometry GPU resource plus its attribute and index GPU resources; `drawable.freeAllGpuResourceOwned()` releases its materials, geometry, and drawable GPU resources. +- Use `destroy()` only when the object is permanently retired. Before calling `destroy()`, ensure the object is removed from the scene, no frame callback, async load, event listener, control, viewport, material, geometry, or app state will use it again, and it is not currently participating in a render/update pass. +- Prefer `freeGPU()` or `freeAllGpuResourceOwned()` for memory-pressure cleanup. Prefer `destroy()` for final teardown after references are severed. +- In component frameworks, cancel pending async work and stop animation/requestRender callbacks before releasing resources in unmount cleanup. + +## Guardrails + +- Do not hand-write code against private renderer internals or repository-only paths. +- Do not assume a server-side runtime; the viewer needs browser DOM and WebGL/WebGL2-capable canvas support. +- Do not construct `Splat` directly. Use loader and utility APIs. +- Do not register app DOM events on the engine canvas. Register on the container because the engine owns the canvas lifecycle and may replace it. +- Do not hold persistent references to the engine canvas. Read it only when needed, use it immediately, and release the reference. +- Do not call `destroy()` on resources that shared objects, cached data, render callbacks, or pending async work may still reference. +- Do not use `destroy()` as the default GPU-memory cleanup method. Use `freeGPU()` for the object's own GPU state, or `freeAllGpuResourceOwned()` when owned child resources should also release GPU state. +- Do not omit cleanup in component frameworks. In React, Vue, Svelte, or similar frameworks, dispose viewer-owned objects and cancel pending async work during unmount. +- Do not invent package exports. Check the LLM API index or installed package types. diff --git a/docs/ai/skills/use-aholo-viewer/agents/claude.md b/docs/ai/skills/use-aholo-viewer/agents/claude.md new file mode 100644 index 0000000..91fedcb --- /dev/null +++ b/docs/ai/skills/use-aholo-viewer/agents/claude.md @@ -0,0 +1,50 @@ +# Claude Instructions: Use Aholo Viewer + +Use these instructions when Claude is helping a user integrate the public `@manycore/aholo-viewer` npm package into a browser application. + +## Activation + +Apply this guidance when the user asks Claude to install, bootstrap, debug, or extend an app that uses `@manycore/aholo-viewer`, especially for: + +- 3D Gaussian Splatting (3DGS) rendering. +- Mesh rendering with `BufferGeometry`, `Mesh`, lights, and materials. +- Viewer setup, camera setup, scene composition, render-loop wiring, or cleanup. +- React, Vue, Svelte, Vite, or plain TypeScript browser integrations. + +## Read First + +Before writing non-trivial code, read the AI-facing docs listed by `https://aholojs.dev/llms.txt`. + +Preferred reading order: + +1. `https://aholojs.dev/llm/manual/getting-started.md` +2. `https://aholojs.dev/llm/manual/basic-concepts.md` +3. `https://aholojs.dev/llm/api/index.md` +4. `https://aholojs.dev/llm/examples/index.md` + +Use the Markdown docs instead of scraping rendered HTML. + +## Implementation Rules + +- Import only public exports from `@manycore/aholo-viewer`; never import internal `@qunhe/*` packages. +- Create a stable application-owned container before calling `createViewer(...)`. +- Attach DOM event listeners to the container or another app-owned element, not to the engine canvas. +- Do not persistently store the engine canvas. If canvas access is unavoidable, read it only at the point of use and discard the reference immediately. +- Use `PerspectiveCamera` or `OrthographicCamera`, then call `viewer.setCamera(camera)`. +- Add objects to `viewer.getScene()`. +- Configure the pipeline with `setViewerConfig(viewer, { pipeline: ... })`. +- Trigger rendering with `viewer.render()` or schedule rendering through `viewer.requestRenderHandler`. +- Do not construct `Splat` directly; load splat data with `SplatLoader` and create renderable splats with `SplatUtils.createSplat(...)`. + +## Cleanup Rules + +- Stop render loops, event listeners, controls, and pending async work before releasing resources. +- Use `freeGPU()` to release an object's own GPU resources while keeping the object reusable. +- Use `freeAllGpuResourceOwned()` when owned child resources should also release GPU state. For example, `geometry.freeAllGpuResourceOwned()` also releases attribute and index GPU resources. +- Use `destroy()` only for final teardown after the object is detached from the scene and no callback, async task, viewport, material, geometry, or app state can use it again. + +## Output Style + +- Prefer concise TypeScript examples over long explanations. +- For full setup steps, link to `https://aholojs.dev/en-US/manual/getting-started/` instead of duplicating the complete tutorial. +- Confirm less common exports against `https://aholojs.dev/llm/api/index.md` before using them. diff --git a/docs/ai/skills/use-aholo-viewer/agents/openai.yaml b/docs/ai/skills/use-aholo-viewer/agents/openai.yaml new file mode 100644 index 0000000..cfa2af7 --- /dev/null +++ b/docs/ai/skills/use-aholo-viewer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: 'Use Aholo Viewer' + short_description: 'Guide AI agents to integrate @manycore/aholo-viewer' + default_prompt: 'Use $use-aholo-viewer to build a Vite integration with @manycore/aholo-viewer.' diff --git a/docs/ai/vibe-coding-guide.md b/docs/ai/vibe-coding-guide.md new file mode 100644 index 0000000..64828c9 --- /dev/null +++ b/docs/ai/vibe-coding-guide.md @@ -0,0 +1,90 @@ +# Vibe Coding Guide + +Concise source for AI-assisted product writing, collaboration, and handoff rules. + +## Work Loop + +1. Read `AGENTS.md`. +2. Use the narrowest project skill for the task. +3. Read `docs/architecture.md` only for package boundaries, generated API docs, build flow, or directory structure. +4. Run `git status --short` before editing and preserve user changes. +5. When reading Chinese files, use `rg` or `Get-Content -Encoding utf8` to avoid PowerShell encoding ambiguity. + +## Product Brief + +- Audience: frontend engineers, SDK documentation readers, and product-demo evaluators. +- Positioning: high-performance 3D Gaussian Splatting (3DGS) rendering for web applications. +- Stage: preparing for public release. +- Visual direction: simple, refined, spacious, comfortable, technical. +- Primary manual-check browser: Chrome. + +Core value: + +- Focused renderer experience for web-based 3DGS scenes. +- High-performance 3DGS rendering. +- Surrounding 3DGS facilities: examples, preview, debugging, docs, and integration support. + +## Skill Map + +- `aholo-viewer`: cross-area work, repo cleanup, workspace scripts, and validation routing. +- `aholo-site`: Astro website, examples, docs UI, Playground, Monaco, runner, preview, presets, URL state. +- `aholo-renderer`: renderer package, public API, build, declarations, release checks. +- `aholo-docs`: docs, manual content, bilingual copy, AI collaboration notes. +- `frontend-design`: Aholo website visual direction, responsive styling, and UI polish. + +## Writing Rules + +- Keep copy concise, concrete, and tool-oriented. +- Avoid placeholders, broad marketing claims, and invented API names. +- Do not compare the renderer to third-party engines or frameworks unless explicitly asked. +- Use actual public renderer exports in snippets. +- Keep Chinese copy technical and concise. +- Keep English copy in an SDK documentation style. +- Keep zh-CN and en-US manual pages structurally parallel. + +## Terms + +```text +3D Gaussian Splatting (3DGS) / 3DGS +Mesh / 网格 +Renderer / 渲染器 +Scene / 场景 +Camera / 相机 +Material / 材质 +Render loop / 渲染循环 +Playground / Playground +Examples / 示例 +Manual / 手册 +API Reference / API 参考 +``` + +## Non-Negotiables + +- Renderer public API exports are user-owned. +- Do not modify `packages/renderer/src/index.ts` exports unless explicitly asked. +- Do not hand-edit `external/egs-core`, `website/.generated/api/`, or generated `dist` folders. +- Do not delete `external/splat-transform`; it is a required workspace package. +- Keep Monaco route-local. +- Keep examples as paired JSON metadata and TypeScript source files. + +## Validation + +```text +Site/Playground/style pnpm.cmd check:website +Renderer/API/release pnpm.cmd check +Package/release pnpm.cmd build +Docs-only repo notes path/reference scan unless imported by website +``` + +If esbuild cannot read the workspace in a sandboxed run, rerun the same command with approved workspace access. + +For browser-based website checks, prefer an already running website service and reuse its URL/port before starting a new dev server. + +## Handoff + +End with: + +- What changed. +- Validation result. +- Skipped checks, risks, or assumptions. +- Follow-up suggestions only when useful. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..58922c6 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,211 @@ +# Project Architecture + +## Overview + +Aholo Viewer is split into two main products: + +- `@manycore/aholo-viewer`: the TypeScript renderer package. +- `@manycore/aholo-viewer-website`: the Astro documentation and playground website. + +The root package coordinates build, check, API generation, and packaging through pnpm workspace scripts. + +## Workspace Layout + +```text +./ + package.json + pnpm-workspace.yaml + AGENTS.md + + website/ + package.json + astro.config.mjs + .generated/ + api/ + src/ + components/ + config/ + content/ + i18n/ + layouts/ + pages/ + playground/ + styles/ + utils/ + + packages/ + renderer/ + package.json + tsconfig.json + src/ + dist/ + + scripts/ + build-package.mjs + check-content.mjs + clean-package.mjs + ensure-submodules.mjs + package-utils.mjs + prepare-egs-types.mjs + generate-api-docs.mjs + + docs/ + architecture.md + ai/ + vibe-coding-guide.md + + external/ + egs-core/ + splat-transform/ +``` + +## Structural Roles + +- Root package: workspace entry point for dependency preparation, renderer build, API generation, website checks, and release packaging. +- `packages/renderer/`: public renderer package source. `src/index.ts` is the public API barrel; package-local files hold math, events, animation, loader, and utility namespaces. +- `website/`: Astro application for home, manual, API reference, examples, and Playground. Route modules live under `pages/`; shared UI lives under `layouts/` and `components/`; route-independent data helpers live under `utils/`. +- `website/src/playground/`: route-local Playground runtime, Monaco integration, renderer adapter, camera control, and example-facing types. +- `website/src/content/`: manual pages, manual assets, and paired example metadata/source files. +- `scripts/`: shared Node automation for submodule readiness, EGS declarations, renderer packaging, API HTML generation, content validation, and clean tasks. +- `external/egs-core`: upstream submodule consumed by workspace packages and renderer packaging scripts. +- `external/splat-transform`: required workspace package used by the manual and examples workflow. + +## Dependency Direction + +```text +external/egs-core workspace packages + -> scripts/prepare-egs-types.mjs + -> renderer type checks and declaration bundling + +packages/renderer/src/index.ts + -> scripts/build-package.mjs + -> packages/renderer/dist + -> website Playground runtime, examples, and type hints + +packages/renderer/src/index.ts + -> scripts/generate-api-docs.mjs + -> website/.generated/api/ + -> website pages +``` + +`website/` may depend on `@manycore/aholo-viewer` through the workspace package. The renderer package should not depend on the website. Website `dev`, `build`, and `check` first run the renderer build, then regenerate API HTML and manifest data. + +`external/egs-core` is an upstream dependency submodule. `external/splat-transform` is a required workspace package. Scripts may read external sources and generate dependency outputs needed for local builds, but repository changes should not hand-edit upstream code unless a task explicitly targets that package. + +## Root Command Graph + +Root scripts keep the workspace build order explicit: + +```text +pnpm dev + -> .egs:types + -> .renderer:build + -> .docs:api + -> check:content + -> .site:dev + +pnpm check + -> .egs:types + -> .renderer:check + -> .renderer:build + -> .docs:api + -> check:content + -> .site:check + +pnpm build + -> build:website + -> .egs:types + -> .renderer:build + -> .docs:api + -> .site:build +``` + +Targeted commands preserve the same boundaries: `check:renderer` prepares EGS types and runs the renderer type check, `check:website` prepares renderer/API outputs before Astro checks, and `docs:api` prepares EGS types before generating API HTML and manifest data. + +## API Documentation Flow + +1. Export public API from `packages/renderer/src/index.ts`. +2. Add concise JSDoc comments to public classes, functions, interfaces, and types. +3. Run `pnpm docs:api`. +4. The generated HTML fragments are written to the ignored local directory `website/.generated/api/{locale}/`. +5. A generated manifest beside those fragments drives API navigation, metadata, and table-of-contents data. +6. Astro API routes inline the TypeDoc HTML through the same DocsLayout flow used by the Manual. + +Do not edit generated API HTML or manifest data by hand. `pnpm dev`, `pnpm build`, and `pnpm check` regenerate it before the website starts. + +## Playground Flow + +Examples live in the Astro content tree as paired metadata and source files: + +```text +website/src/content/examples/ + basic-scene.json + basic-scene.ts +``` + +The JSON file holds title, description, tags, accent, and order. The same-named TypeScript file is imported with `?raw`, passed to Monaco, and rendered in the Playground preview. Playground URLs support: + +- `example`: selected preset slug. +- `code`: `lz-string` compressed editor source. + +Resetting or switching presets clears custom `code` and returns to a clean example URL. + +Playground type hints use `packages/renderer/dist/index.d.ts` through the workspace package. Do not add a second handwritten renderer declaration file under `website/src/playground/`. + +## Content Validation + +`pnpm check:content` scans manual pages, localized slugs, heading-depth parity, example source pairs, manual image references, orphan manual images, and internal-only documentation links. `pnpm check` and `pnpm check:website` run it before Astro checks. + +## Website Style Layers + +Website styles are split by responsibility so global changes stay small and feature surfaces remain easy to reason about: + +```text +website/src/styles/ + theme.css Design tokens: color, type, radii, shadows, layout widths + global.css Reset, base document styles, buttons, code, simple primitives + site.css Header, language/theme controls, listing-page shell + home.css Immersive home page and home-only interaction states + examples.css Examples list and example detail viewer chrome + docs.css Manual/API documentation layout and prose + playground.css Playground workspace, editor, preview, inspector chrome +``` + +`BaseLayout.astro` imports `theme.css`, `global.css`, and `site.css`. Feature pages import their own feature stylesheet only when needed. Avoid moving feature-specific selectors back into `global.css`. + +The home page intentionally keeps a darker immersive style around the 3D canvas. Docs, API, examples, and Playground should stay lighter, more restrained, and tool-like. + +## Build Outputs + +- Website output: `website/dist/` +- Astro cache: `website/.astro/` +- Renderer output: `packages/renderer/dist/` + - `index.js`: bundled public runtime. + - `index.d.ts`: bundled public declarations, including upstream EGS types used by exported symbols. + - `splat-worker.js`: bundled worker referenced by the runtime. + +Root-level `dist/` and `.astro/` are stale and should not exist after the workspace split. + +Ignored generated folders can be removed when a clean workspace is needed, but they should be produced by commands rather than hand-edited. + +## Architecture Improvement Opportunities + +1. Keep API navigation metadata generated from the same source as API HTML. + + `scripts/generate-api-docs.mjs` derives API namespaces, categories, entries, and TOC data from `packages/renderer/src/index.ts`. Keep any future API navigation changes in that generated manifest flow to avoid drift when public namespaces change. + +2. Keep Playground integration behind the website adapter. + + `website/src/playground/renderer-adapter.ts` is the boundary between Monaco-transpiled examples, preview lifecycle, camera control, runtime stats, and `@manycore/aholo-viewer`. New preview behavior should stay in the website adapter unless it is a reusable renderer capability that belongs in `packages/renderer/`. + +3. Make external EGS consumption more declarative. + + `prepare-egs-types.mjs` discovers and prepares EGS declarations, while `build-package.mjs` discovers runtime packages for esbuild aliases. A small allow-list or manifest for consumed EGS packages would make renderer packaging easier to audit and less dependent on directory traversal. + +4. Treat generated output ownership as an architectural contract. + + `packages/renderer/dist/`, `website/.generated/api/`, and temporary API cache folders are command-owned. Any new generated surface should also have an owning script, stale-file cleanup, and a validation path before it is referenced by website routes or package exports. + +5. Keep validation mapped to changed surfaces. + + Current checks are well split between renderer, website, content, and package build flows. As the site grows, add new validation to the narrowest existing command first, then wire it into `check` only when it protects a cross-area contract. diff --git a/external/splat-dev-server/CHANGELOG.md b/external/splat-dev-server/CHANGELOG.md new file mode 100644 index 0000000..03241d7 --- /dev/null +++ b/external/splat-dev-server/CHANGELOG.md @@ -0,0 +1,5 @@ +# ChangeLOG + +## 1.0.0 + +- 发包正式包 diff --git a/external/splat-dev-server/LICENSE b/external/splat-dev-server/LICENSE new file mode 100644 index 0000000..8d6424f --- /dev/null +++ b/external/splat-dev-server/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright © 2026 MANYCORE, INC. + +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/external/splat-dev-server/README.md b/external/splat-dev-server/README.md new file mode 100644 index 0000000..878161d --- /dev/null +++ b/external/splat-dev-server/README.md @@ -0,0 +1,22 @@ +# splat-dev-server + +A 3DGS dev server + +## Requirement + +- node >= 22.22.1 + +## Usage + +```bash +npm install @manycore/aholo-splat-dev-server -g + +splat-dev-server [options] + +Options: + --help Show help [boolean] + --version Show version number [boolean] + -a, --address Address to listen [string] [default: "127.0.0.1"] + -p, --port Port to listen [number] [default: 3000] +``` + diff --git a/external/splat-dev-server/bin/merge.js b/external/splat-dev-server/bin/merge.js new file mode 100755 index 0000000..3513a86 --- /dev/null +++ b/external/splat-dev-server/bin/merge.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +import yargs from 'yargs/yargs'; +import { hideBin } from 'yargs/helpers'; +import { merge } from '../src/merge.js'; +import packageJson from '../package.json' with { type: 'json' } + +const argv = yargs(hideBin(process.argv)) + .version(packageJson.version) + .usage('merge-lod -i -o ') + .option('input', { + alias: 'i', + array: true, + type: 'string', + demandOption: true, + description: 'Input lod meta files(lod-meta.json)' + }) + .option('output', { + alias: 'o', + type: 'string', + demandOption: true, + description: 'Output directory' + }) + .argv; + +merge(argv.input, argv.output); diff --git a/external/splat-dev-server/bin/server.js b/external/splat-dev-server/bin/server.js new file mode 100755 index 0000000..53fed3d --- /dev/null +++ b/external/splat-dev-server/bin/server.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import yargs from 'yargs/yargs'; +import { hideBin } from 'yargs/helpers'; +import { start } from '../src/server.js'; +import packageJson from '../package.json' with { type: 'json' } + +const argv = yargs(hideBin(process.argv)) + .version(packageJson.version) + .usage('splat-dev-server [options] ') + .option('address', { + alias: 'a', + type: 'string', + default: '127.0.0.1', + describe: 'Address to listen' + }) + .option('port', { + alias: 'p', + type: 'number', + default: 3000, + description: 'Port to listen' + }) + .argv; + +start(argv.address, argv.port, argv._[0]); diff --git a/external/splat-dev-server/package.json b/external/splat-dev-server/package.json new file mode 100644 index 0000000..7af666f --- /dev/null +++ b/external/splat-dev-server/package.json @@ -0,0 +1,40 @@ +{ + "name": "@manycore/aholo-splat-dev-server", + "version": "1.0.1", + "description": "", + "author": "egs", + "repository": { + "type": "git", + "url": "https://github.com/manycoretech/aholo-viewer.git", + "directory": "external/splat-dev-server" + }, + "license": "MIT", + "engines": { + "node": ">= 22.22.1" + }, + "keywords": [ + "aholo", + "cli", + "3d-gaussian-splatting", + "gaussian-splatting", + "server" + ], + "type": "module", + "bin": { + "splat-dev-server": "bin/server.js", + "merge-lod": "bin/merge.js" + }, + "files": [ + "src/", + "CHANGELOG.md" + ], + "dependencies": { + "express": "^5.2.1", + "yargs": "^18.0.0" + }, + "binary": { + "napi_versions": [ + 8 + ] + } +} diff --git a/external/splat-dev-server/src/merge.js b/external/splat-dev-server/src/merge.js new file mode 100644 index 0000000..0bfe367 --- /dev/null +++ b/external/splat-dev-server/src/merge.js @@ -0,0 +1,76 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; + +function mergeBounds(bounds) { + if (!bounds || bounds.length === 0) return { min: [0, 0, 0], max: [0, 0, 0] }; + const min = [ + Math.min(...bounds.map(b => b.min[0])), + Math.min(...bounds.map(b => b.min[1])), + Math.min(...bounds.map(b => b.min[2])) + ]; + + const max = [ + Math.max(...bounds.map(b => b.max[0])), + Math.max(...bounds.map(b => b.max[1])), + Math.max(...bounds.map(b => b.max[2])) + ]; + return { min, max }; +} + + +export async function merge(input, output) { + try { + await fs.stat(output); + } catch { + await fs.mkdir(output, { recursive: true }); + } + const meta = { + magicCode: 0x262834, + type: 'lod-splat', + version: '1.0', + counts: 0, + shDegree: 0, + levels: 5, + forwardBox: { min: [0, 0, 0], max: [0, 0, 0] }, + files: [], + permanentFiles: [], + tree: [], + }; + const inputMetaList = input.map(i => ({ + basedir: path.dirname(i), + file: i, + output: [] + })); + let index = 0; + for (const inputMeta of inputMetaList) { + const data = JSON.parse(await fs.readFile(inputMeta.file, 'utf-8')); + meta.counts += data.counts; + meta.shDegree = Math.max(meta.shDegree, data.shDegree); + meta.forwardBox = mergeBounds([meta.forwardBox, data.forwardBox]); + const copyTasks = []; + for (const file of data.files) { + const name = `chunk_${index}${path.extname(file)}`; + inputMeta.output.push(index); + meta.files.push(name); + index++; + const i = path.join(inputMeta.basedir, file); + const o = path.join(path.join(output, name)); + console.log(`${i} -> ${o}`); + copyTasks.push(fs.copyFile(i, o)); + } + for (const permanentFile of data.permanentFiles) { + meta.permanentFiles.push(inputMeta.output[permanentFile]); + } + for (const node of data.tree) { + meta.tree.push({ + bound: node.bound, + lods: node.lods.map(l => ({ + ...l, + file: inputMeta.output[l.file] + })) + }); + } + await Promise.all(copyTasks); + } + await fs.writeFile(path.join(output, 'lod-meta.json'), JSON.stringify(meta), 'utf-8'); +} diff --git a/external/splat-dev-server/src/server.js b/external/splat-dev-server/src/server.js new file mode 100644 index 0000000..8b72e78 --- /dev/null +++ b/external/splat-dev-server/src/server.js @@ -0,0 +1,51 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; + + +function createApp(address, port, publicPath) { + const app = express(); + const rootDir = path.resolve(publicPath); + app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Range'); + + if (req.method === 'OPTIONS') { + return res.sendStatus(204); + } + + return next(); + }); + + app.get(/lod-meta.json$/, async (req, res, next) => { + const file = path.join(rootDir, req.path); + try { + const content = JSON.parse(await fs.readFile(file, 'utf-8')); + const dirname = path.dirname(req.path); + content.files = content.files.map(f => `http://${address}:${port}${dirname}/${f}`) + res.header('Cache-Control', 'no-cache'); + return res.json(content); + } catch { + return next(); + } + }); + app.use(express.static(rootDir, { + setHeaders: res => { + res.header('Cache-Control', 'no-cache'); + } + })); + return app; +} + +export function start(address, port, publicPath) { + const app = createApp(address, port, publicPath); + app.listen(port, address, () => { + console.log('\n========================================'); + console.log('Splat dev server started'); + console.log(`Host: ${address}:${port}`); + console.log(`Root: ${publicPath}`); + console.log(`Base URL: http://${address}:${port}`); + console.log('========================================\n'); + }); +} diff --git a/lint-staged.config.js b/lint-staged.config.js new file mode 100644 index 0000000..3846eca --- /dev/null +++ b/lint-staged.config.js @@ -0,0 +1,4 @@ +export default { + '**/*.{ts,tsx}': ['oxfmt --write --no-error-on-unmatched-pattern', 'oxlint --fix --no-error-on-unmatched-pattern'], + '**/*.{md,MD,json,txt,yml,yaml}': ['oxfmt --write --no-error-on-unmatched-pattern'], +}; diff --git a/oxfmt.config.ts b/oxfmt.config.ts new file mode 100644 index 0000000..2799b61 --- /dev/null +++ b/oxfmt.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'oxfmt'; + +export default defineConfig({ + printWidth: 120, + singleQuote: true, + arrowParens: 'avoid', + sortPackageJson: false, + endOfLine: 'lf', + ignorePatterns: [ + '*.log', + '.env', + '.env.*', + '.codex/', + '.vscode/', + '**/build/**', + '**/dist/**', + // website + '**/.astro/**', + '**/.generated/**', + // externals + 'external/splat-dev-server/**', + ], +}); diff --git a/oxlint.config.ts b/oxlint.config.ts new file mode 100644 index 0000000..96087ae --- /dev/null +++ b/oxlint.config.ts @@ -0,0 +1,42 @@ +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + env: { + browser: true, + node: true, + es2026: true, + }, + plugins: ['eslint', 'typescript', 'unicorn', 'oxc', 'promise', 'import'], + ignorePatterns: [ + '.github/*', + '**/build/**', + '**/dist/**', + '**/bin/**', + '**/scripts/**', + // website + '**/.astro/**', + '**/.generated/**', + // externals + 'external/splat-dev-server/**', + ], + rules: { + 'no-console': 'off', + 'guard-for-in': 'off', + 'no-shadow': 'off', + 'no-use-before-define': 'off', + 'typescript/prefer-for-of': 'off', + 'no-loss-of-precision': 'off', + 'no-unused-expressions': [ + 'deny', + { + allowShortCircuit: true, + allowTernary: true, + }, + ], + 'unicorn/no-new-array': 'off', + 'no-extra-boolean-cast': 'off', + 'typescript/no-this-alias': 'off', + 'typescript/no-duplicate-enum-values': 'off', + 'erasing-op': 'off', + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..eb54b4c --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "@manycore/aholo", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@11.10.0", + "type": "module", + "engines": { + "node": ">=22.22.1" + }, + "scripts": { + "prepare": "husky", + "pnpm:devPreinstall": "node scripts/ensure-submodules.mjs", + "dev": "pnpm run .renderer:build && pnpm run .docs:api && pnpm run check:content && pnpm run .site:dev", + "build": "pnpm run build:website", + "build:renderer": "pnpm run .renderer:build", + "build:website": "pnpm run .renderer:build && pnpm run .docs:api && pnpm run .site:build && pnpm run .docs:llm", + "check": "pnpm run check:style && pnpm run .renderer:check && pnpm run .renderer:build && pnpm run .docs:api && pnpm run check:content && pnpm run .site:check && pnpm run .site:build && pnpm run .docs:llm", + "check:style": "oxfmt --check && oxlint", + "check:content": "node scripts/check-content.mjs", + "check:renderer": "pnpm run .renderer:check", + "check:website": "pnpm run .renderer:build && pnpm run .docs:api && pnpm run check:content && pnpm run .site:check", + "clean": "pnpm run .renderer:clean && pnpm run .site:clean", + "docs:api": "pnpm run build:renderer && pnpm run .docs:api", + "format": "oxfmt", + "preview": "pnpm run .site:preview", + ".docs:api": "node scripts/generate-api-docs.mjs", + ".docs:llm": "node scripts/generate-llm-docs.mjs", + ".renderer:build": "pnpm --filter @manycore/aholo-viewer run .build", + ".renderer:check": "pnpm --filter @manycore/aholo-viewer run .check", + ".renderer:clean": "pnpm --filter @manycore/aholo-viewer run .clean", + ".site:dev": "pnpm --filter @manycore/aholo-viewer-website run .dev", + ".site:build": "pnpm --filter @manycore/aholo-viewer-website run .build", + ".site:preview": "pnpm --filter @manycore/aholo-viewer-website run .preview", + ".site:check": "pnpm --filter @manycore/aholo-viewer-website run .check", + ".site:clean": "pnpm --filter @manycore/aholo-viewer-website run .clean" + }, + "devDependencies": { + "@internal/utils": "workspace:*", + "@manycore/aholo-splat-dev-server": "workspace:*", + "@manycore/aholo-splat-transform": "workspace:*", + "husky": "^9.1.7", + "lint-staged": "^17.0.8", + "oxfmt": "^0.58.0", + "oxlint": "^1.73.0", + "typedoc": "^0.28.20", + "typescript": "^6.0.3" + } +} diff --git a/packages/renderer/CHANGELOG.md b/packages/renderer/CHANGELOG.md new file mode 100644 index 0000000..8769f6e --- /dev/null +++ b/packages/renderer/CHANGELOG.md @@ -0,0 +1,75 @@ +# ChangeLOG + +## 1.5.1 + +1. Fixes + - fix `lod` scheduling to avoid temporarily creating too many active splats. +2. Changes + - move splat sorting ownership to `SplatUtils`; `SplatLoader` now only parses files. + +## 1.5.0 + +1. Features + - add `Splatting.pack.forceUnstableEnabled` to force the complete rendering pipeline. + - add `Splatting.sort.frustumCullingEnabled` to pre-cull splats for better performance. + - this can make black borders during camera rotation more visible, so enable it carefully in production scenes. + - add `Limits` for describing `IRenderer` limits. + - some `Capabilities` fields are now marked as deprecated. + - add `lod.proxy` to reduce the number of runtime `Splat` objects. + - reduces `pack` and `precalculate` GPU cost by 50%-90% in real scenes. +2. Fixes + - fix `SplatHighlightKernel` highlights being offset when `cameraRelativeEnabled` is enabled. + - fix `clear` not taking effect correctly when `MRT` is enabled. + - fix invalid data when using low-precision packing. + - fix the `LodMeta.version` type definition. +3. Changes + - optimize texture-size calculation to improve rendering performance for scenes with a single `Splat` object. + - move sorting to a dedicated worker to avoid long parsing tasks blocking sorting. + +## 1.4.1 + +1. Fixes + - fix `packCameraRelativeCenterIsDirty` being incorrectly evaluated as `true` when camera-relative center packing is disabled. + +## 1.4.0 + +1. Features + - add `texture-loader`, now `downloadTexture` is a generic texture load + - supported types: image types(png, jpg, webp, etc...), ktx2 and dds + - add grouped `Splatting` config: `pack`, `raster`, `sort`, and `composite`. + - add high-precision and camera-relative splat packing, plus high-precision sorting. + - `combineSplatData` target `SplatData` support. +2. Changes + - adjust splat-related default parameters from the underlying renderer pipeline. + +## 1.3.0 + +1. Features + - `SplatUtils`add support for `center` and `ellipsoid` + > **`constructor` has been changed, migrate: `new SplatBVH(operator)` -> `new BVH(SplatCenterPrimitiveSource(operator))`** +2. Fixes + - `SplatUtils`state texture type change to `r8uint` + +## 1.2.9 + +1. Features + - use `api-extractor` to rollup dts. + - add `esz` and `spzV4` format support. +2. Fixes + - fix typing for `MeshBasicMaterial.setValues` + - fix typing for `MeshPhongMaterial.setValues` + - Simplify some material typings + - fix type only classes + - cleanup `package.json` + +## 1.1.0 + +1. Features + - upgrade packages: `typescript@^6.0.3`, `tslib@^2.8.1` + - sync base packages + - remove unused module `render-cloud` + +## 1.0.0 + +1. Features + - First release diff --git a/packages/renderer/LICENSE b/packages/renderer/LICENSE new file mode 100644 index 0000000..8d6424f --- /dev/null +++ b/packages/renderer/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright © 2026 MANYCORE, INC. + +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/packages/renderer/README.md b/packages/renderer/README.md new file mode 100644 index 0000000..09801f4 --- /dev/null +++ b/packages/renderer/README.md @@ -0,0 +1,28 @@ +# @manycore/aholo-viewer + +`@manycore/aholo-viewer` is the full-featured npm package for building browser-based Aholo Viewer applications. It includes the rendering runtime and TypeScript declarations for rendering 3D Gaussian Splatting (3DGS) and mesh content in WebGL/WebGL2-capable browsers. + +## Install + +```bash +npm install --save @manycore/aholo-viewer +``` + +The package is ESM-only and ships TypeScript declarations through `dist/index.d.ts`. + +## Quick Start + +Follow the [Getting Started guide](https://aholojs.dev/en-US/manual/getting-started/) to create a Vite app, initialize a viewer, load a 3DGS splat, and render the first frame. + +## Documentation + +- Website: https://aholojs.dev/ +- Getting Started: https://aholojs.dev/en-US/manual/getting-started/ +- API Reference: https://aholojs.dev/en-US/api/ +- Examples: https://aholojs.dev/en-US/examples/ +- AI-friendly docs: https://aholojs.dev/llms.txt +- AI coding skill: https://github.com/manycoretech/aholo-viewer/blob/master/docs/ai/skills/use-aholo-viewer/SKILL.md + +## License + +MIT diff --git a/packages/renderer/package.json b/packages/renderer/package.json new file mode 100644 index 0000000..593de6b --- /dev/null +++ b/packages/renderer/package.json @@ -0,0 +1,62 @@ +{ + "name": "@manycore/aholo-viewer", + "version": "1.5.1", + "description": "A compact TypeScript renderer core used by the Aholo Viewer website and examples.", + "author": "egs", + "repository": { + "type": "git", + "url": "https://github.com/manycoretech/aholo-viewer.git", + "directory": "packages/renderer" + }, + "license": "MIT", + "keywords": [ + "aholo", + "renderer", + "viewer", + "webgl", + "webgl2", + "3d", + "3d-gaussian-splatting", + "gaussian-splatting", + "canvas", + "html5" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "CHANGELOG.md" + ], + "scripts": { + ".build": "pnpm run .egs:types && node ../../scripts/build-package.mjs", + ".check": "tsc -p tsconfig.json --noEmit", + ".clean": "node ../../scripts/clean-package.mjs", + ".egs:types": "node ../../scripts/prepare-egs-types.mjs" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@qunhe/egs": "workspace:*", + "@qunhe/egs-animation": "workspace:*", + "@qunhe/egs-draco-loader": "workspace:*", + "@qunhe/egs-gltf-loader": "workspace:*", + "@qunhe/egs-lib": "workspace:*", + "@qunhe/egs-splat-loader": "workspace:*", + "@qunhe/egs-splat-utils": "workspace:*", + "@qunhe/egs-texture-loader": "workspace:*", + "@types/node": "^22.20.0", + "esbuild": "^0.28.1", + "tslib": "^2.8.1", + "typescript": "^6.0.3" + } +} diff --git a/packages/renderer/src/animation.ts b/packages/renderer/src/animation.ts new file mode 100644 index 0000000..b6e8f8b --- /dev/null +++ b/packages/renderer/src/animation.ts @@ -0,0 +1,13 @@ +export { + AnimationPlugin, + SkeletonUpdatedEvent, + AnimationMixer, + AnimationAction, + AnimationFinishEvent, + Skeleton, + type AnimationClip, + type KeyframeTrack, + Blend, + Loop, + InterpolationMode, +} from '@qunhe/egs-animation'; diff --git a/packages/renderer/src/draco-loader.ts b/packages/renderer/src/draco-loader.ts new file mode 100644 index 0000000..f634a58 --- /dev/null +++ b/packages/renderer/src/draco-loader.ts @@ -0,0 +1 @@ +export { type ParseConfig, parseDracoBuffer } from '@qunhe/egs-draco-loader'; diff --git a/packages/renderer/src/events.ts b/packages/renderer/src/events.ts new file mode 100644 index 0000000..edac936 --- /dev/null +++ b/packages/renderer/src/events.ts @@ -0,0 +1 @@ +export { SplatSortedEvent, SplatRenderingStabilityChangedEvent } from '@qunhe/egs'; diff --git a/packages/renderer/src/gltf-loader.ts b/packages/renderer/src/gltf-loader.ts new file mode 100644 index 0000000..ca405d6 --- /dev/null +++ b/packages/renderer/src/gltf-loader.ts @@ -0,0 +1 @@ +export { type LoaderConfig, type ParseResult, loadGLTF } from '@qunhe/egs-gltf-loader'; diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts new file mode 100644 index 0000000..7c6c749 --- /dev/null +++ b/packages/renderer/src/index.ts @@ -0,0 +1,376 @@ +export { + type Viewer, + type Viewport, + type IViewerContext, + createViewerContext, + Object3D, + Scene3D, + Camera as Camera3D, + PerspectiveCamera, + OrthographicCamera, + type Light, + AmbientLight, + DirectionalLight, + PointLight, + SpotLight, + BufferAttribute, + InstancedBufferAttribute, + BufferGeometry, + FatLineBufferGeometry, + type Material, + PointsMaterial, + LineBasicMaterial as LineMaterial, + FatLineMaterial, + Splat, + Mesh, + Sprite, + Points, + Line, + LineSegments, + FatLineSegments, + InstanceMesh, + WebGLDrawMode as DrawMode, + WebGLTextureWrap as SamplerWrap, + WebGLTextureFilter as SamplerFilter, + Side, + DepthModes, + Blending, + WebGLBlendingEquation as BlendingEquation, + WebGLBlendingDst as BlendingFactor, + WebGLStencilOp as StencilOp, + WebGLStencilFunc as StencilFunc, + Layers, + type Intersection, + Raycaster, + TypeAssert, + DrawableRenderMode, + ToneMapping, + FilterTarget, + SplatState, + BackgroundMode, + SourceTexture, + TextureDimension, + TextureViewDimension, + TextureFormat, + + // Math + Box3, + Color, + type ReadonlyColor, + Euler, + Frustum, + Matrix3, + type ReadonlyMatrix3, + Matrix4, + type ReadonlyMatrix4, + Plane, + Quaternion, + Ray, + Sphere, + Vector2, + type ReadonlyVector2, + Vector3, + type ReadonlyVector3, + Vector4, + type ReadonlyVector4, +} from '@qunhe/egs'; + +export { IntersectContainment, BVH, type BVHNode, type BVHSource } from '@qunhe/egs-lib'; +export { downloadTexture, type LoaderOptions } from '@qunhe/egs-texture-loader'; + +import * as Events from './events.js'; +import * as Animation from './animation.js'; +import * as SplatLoader from './splat-loader.js'; +import * as DracoLoader from './draco-loader.js'; +import * as GLTFLoader from './gltf-loader.js'; +import * as SplatUtils from './splat-utils.js'; + +import { + Application, + BackgroundMode, + type EngineInitializeConfig, + Viewer, + type Viewport, + Texture, + ToneMapping, + Vector3, + Color, + Vector4, + setViewerConfig as originSetViewerConfig, + MeshBasicMaterial as BaseMeshBasicMaterial, + MeshPhongMaterial as BaseMeshPhongMaterial, + SpriteMaterial as BaseSpriteMaterial, + type SourceTexture, + __INTERNAL__, +} from '@qunhe/egs'; + +import InstancedBufferGeometry = __INTERNAL__.InstancedBufferGeometry; +import CompressedSplat = __INTERNAL__.CompressedSplat; +import SuperCompressedSplat = __INTERNAL__.SuperCompressedSplat; +import SogSplat = __INTERNAL__.SogSplat; + +export const MeshBasicMaterial: typeof BaseMeshBasicMaterial = BaseMeshBasicMaterial; +export const MeshPhongMaterial: typeof BaseMeshPhongMaterial = BaseMeshPhongMaterial; +export const SpriteMaterial: typeof BaseSpriteMaterial = BaseSpriteMaterial; + +export interface IBasicBackgroundConfig { + color?: Color; + alpha?: number; + texture?: Texture | null; +} + +export interface IEnvMapBackgroundConfig { + texture?: Texture; + luma?: number; + verticalRotation?: number; + horizonRotation?: number; + reverseVertical?: boolean; + reverseHorizon?: boolean; +} + +export interface IGradientBackgroundConfig { + skyColor?: Color; + groundColor?: Color; +} + +export interface ISkyBackgroundConfig { + enablePreSkyMap?: boolean; + luminance?: number; + turbidity?: number; + rayleigh?: number; + mieCoefficient?: number; + mieDirectionalG?: number; +} + +export interface IBackgroundPluginConfig { + /** + * @default `true` + */ + enabled?: boolean; + /** + * up for background rendering, will effect ground and background + * @default `Vector3(0.0, 0.0, 1.0)` + */ + up?: Vector3; + ground?: { + /** + * enable ground grid + * @default `true` + */ + enabled?: boolean; + /** + * default ground grid size + * @default `1000000` + */ + gridSize?: number; + /** + * grid A gap + * @default `500` + */ + gridGapSizeA?: number; + /** + * grid A color + * @default `Color(1.0, 1.0, 1.0)` + */ + colorA?: Color; + /** + * grid A line width + * @default `1` + */ + lineWidthA?: number; + /** + * grid B gap + * @default `5000` + */ + gridGapSizeB?: number; + /** + * grid B color + * @default `Color(1.0, 1.0, 1.0)` + */ + colorB?: Color; + /** + * grid B line width + * @default `1` + */ + lineWidthB?: number; + /** + * enable ground color shading, by default will only render noise for ground. + * @default `false` + */ + isGroundColorEnabled?: boolean; + /** + * ground color + * @default `Color(0.7, 0.7, 0.7)` + */ + groundColor?: Color; + }; + background?: { + /** + * @default `BackgroundMode.BasicBackground` + */ + active?: BackgroundMode; + /** + * basic color and texture + * active by `BackgroundMode.BasicBackground` + */ + basic?: IBasicBackgroundConfig; + /** + * sphere envmap + * active by `BackgroundMode.EnvMapBackground` + */ + envmap?: IEnvMapBackgroundConfig; + /** + * sphere gradient + * active by `BackgroundMode.GradientBackground` + */ + gradient?: IGradientBackgroundConfig; + /** + * sky box + * active by `BackgroundMode.SkyBackground` + */ + sky?: ISkyBackgroundConfig; + }; +} + +export interface ICompositePluginConfig { + enabled?: boolean; + multiSamplingEnabled?: boolean; + staticFrameCacheEnabled?: boolean; +} + +export interface ISplattingPluginConfig { + enabled?: boolean; + /** + * gaussian pack + */ + pack?: { + highPrecisionEnabled?: boolean; + precalculateEnabled?: boolean; + cameraRelativeEnabled?: boolean; + cameraRelativeUpdateDistanceThreshold?: number; + sortedLayoutEnabled?: boolean; + }; + /** + * gaussian raster + */ + raster?: { + mode?: __INTERNAL__.SplattingRenderMode; + preBlurAmount?: number; + blurAmount?: number; + focalAdjustment?: number; + maxStdDev?: number; + maxPixelRadius?: number; + detailCullingThreshold?: number; + normalizedFalloff?: boolean; + selectedColor?: Vector4; + }; + /** + * gaussian sorting + */ + sort?: { + highPrecisionEnabled?: boolean; + minIntervalMs?: number; + sortSplatDistance?: number; + sortSplatCoorient?: number; + sortCameraDistance?: number; + sortCameraCoorient?: number; + depthBias?: number; + }; + /** + * composite before output + */ + composite?: { + enabled?: boolean; + highPrecisionEnabled?: boolean; + }; + /** + * tone mapping functions + */ + toneMapping?: { + enabled?: boolean; + toneMapping?: ToneMapping; + exposure?: number; + }; + /** + * gaussian center highlight + */ + highlightKernel?: { + enabled?: boolean; + size?: number; + color?: number; + }; +} + +export interface ITaaPluginConfig { + /** + * enabled static TAA + * @default `true` + */ + enabled?: boolean; +} + +export interface IPipelineConfig { + /** + * background and ground + */ + Background?: IBackgroundPluginConfig; + /** + * output Composite, used to optimize multi viewport rendering + */ + Composite?: ICompositePluginConfig; + /** + * gaussian splatting + */ + Splatting?: ISplattingPluginConfig; + /** + * static TAA + */ + TAA?: ITaaPluginConfig; +} + +export interface IViewerConfig { + /** + * pixel ratio used for rendering + * @default `1.0` + * @remarks + * default the renderer will use physical pixel in rendering. + * if `devicePixelRatio` is large on some device, will cause performance issue, could set it to `1 / devicePixelRatio` + */ + pixelRatio?: number; + /** + * render pipeline config + */ + pipeline?: IPipelineConfig; +} + +export function createViewer(name: string, container: HTMLElement, config: EngineInitializeConfig) { + return Application.getInstance().createViewer(name, container, config); +} + +export function setViewerConfig(ctx: Viewer | Viewport, config: IViewerConfig) { + const viewerConfig: __INTERNAL__.IViewerConfig = {}; + if (config.pixelRatio) { + viewerConfig.canvas = { + renderPixelRatio: config.pixelRatio, + }; + } + if (config.pipeline) { + viewerConfig.effects = { + __INTERNAL__: config.pipeline, + }; + } + originSetViewerConfig(viewerConfig, ctx.config); +} + +export { + InstancedBufferGeometry, + CompressedSplat, + SuperCompressedSplat, + SogSplat, + Events, + Animation, + SplatLoader, + DracoLoader, + GLTFLoader, + SplatUtils, +}; diff --git a/packages/renderer/src/splat-loader.ts b/packages/renderer/src/splat-loader.ts new file mode 100644 index 0000000..688f852 --- /dev/null +++ b/packages/renderer/src/splat-loader.ts @@ -0,0 +1,21 @@ +export { + KsplatFile, + LccFile, + PlyFile, + SogFile, + SplatFile, + SpzFile, + type IFile, + SplatData, + RawSplatData, + CompressedSplatData, + SuperCompressedSplatData, + SogSplatData, + ISamplerFormat, + type ISampler, + SplatFileType, + SplatPackType, + parseSplatData, + type ISingleSplat, + detectSplatFileType, +} from '@qunhe/egs-splat-loader'; diff --git a/packages/renderer/src/splat-utils.ts b/packages/renderer/src/splat-utils.ts new file mode 100644 index 0000000..0aca698 --- /dev/null +++ b/packages/renderer/src/splat-utils.ts @@ -0,0 +1,16 @@ +export { + SplatOperator, + SplatCenterPrimitiveSource, + SplatEllipsoidPrimitiveSource, + type SplatCenterPrimitive, + type SplatEllipsoidPrimitive, + combineSplatData, + computeDenseBox, + createSplat, + createSplatData, + createSplatModifyData, + transformSplatFile, + type LodMeta, + type LodConfig, + LodSplat, +} from '@qunhe/egs-splat-utils'; diff --git a/packages/renderer/tsconfig.json b/packages/renderer/tsconfig.json new file mode 100644 index 0000000..10b1b2f --- /dev/null +++ b/packages/renderer/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declarationMap": true, + "noEmit": false, + "sourceMap": true, + "strictPropertyInitialization": false, + "strictFunctionTypes": false, + "useUnknownInCatchVariables": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["dist"] +} diff --git a/packages/splat-transform-native/.gitattributes b/packages/splat-transform-native/.gitattributes new file mode 100644 index 0000000..5b9333d --- /dev/null +++ b/packages/splat-transform-native/.gitattributes @@ -0,0 +1,2 @@ +*.node filter=lfs diff=lfs merge=lfs -text +*.dll filter=lfs diff=lfs merge=lfs -text diff --git a/packages/splat-transform-native/.gitignore b/packages/splat-transform-native/.gitignore new file mode 100644 index 0000000..6c061bb --- /dev/null +++ b/packages/splat-transform-native/.gitignore @@ -0,0 +1 @@ +splat-transform.node diff --git a/packages/splat-transform-native/source/.clang-format b/packages/splat-transform-native/source/.clang-format new file mode 100644 index 0000000..d52edff --- /dev/null +++ b/packages/splat-transform-native/source/.clang-format @@ -0,0 +1,23 @@ +Language: Cpp +BasedOnStyle: LLVM +UseTab: Never +IndentWidth: 4 +TabWidth: 4 +BreakBeforeBraces: Attach +AllowShortIfStatementsOnASingleLine: false +IndentCaseLabels: false +ColumnLimit: 0 +AccessModifierOffset: -4 +PointerAlignment: Left +SpaceAfterTemplateKeyword: false +Cpp11BracedListStyle: false +SpaceBeforeCpp11BracedList: true +AlignAfterOpenBracket: DontAlign +ContinuationIndentWidth: 4 +InsertNewlineAtEOF: true +# BreakAfterOpenBracketBracedList: true +# BreakAfterOpenBracketFunction: true +# BreakAfterOpenBracketIf: true +# BreakBeforeCloseBracketBracedList: true +# BreakBeforeCloseBracketFunction: true +# BreakBeforeCloseBracketIf: true diff --git a/packages/splat-transform-native/source/CMakeLists.txt b/packages/splat-transform-native/source/CMakeLists.txt new file mode 100644 index 0000000..c78294b --- /dev/null +++ b/packages/splat-transform-native/source/CMakeLists.txt @@ -0,0 +1,74 @@ +cmake_minimum_required(VERSION 3.31) +cmake_policy(SET CMP0091 NEW) +cmake_policy(SET CMP0042 NEW) + +project(binding) + +find_package(Threads REQUIRED) +find_package(Eigen3 CONFIG REQUIRED) +find_package(nanoflann CONFIG REQUIRED) +find_package(cmake-js CONFIG REQUIRED) +find_package(WebP CONFIG REQUIRED) +find_package(libavif CONFIG REQUIRED) + +set(BINDING_BINARY_DIR "${CMAKE_CURRENT_BINARY_DIR}" CACHE PATH "node binding output dir") + +add_library(${PROJECT_NAME} SHARED) +list(APPEND BINDING_SOURCE_FILES + "${CMAKE_CURRENT_SOURCE_DIR}/src/thread_pool.cpp" + # GAUSSIAN SPLAT + "${CMAKE_CURRENT_SOURCE_DIR}/src/splat/splat.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/splat/splat_lod.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/splat/splat_block.cpp" + # NODE API FILES + "${CMAKE_CURRENT_SOURCE_DIR}/src/node/api_splat.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/node/api_spatial.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/node/api_thread_pool.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/node/api_webp.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/node/api_avif.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/node/binding.cpp" +) +list(APPEND BINDING_HEADER_FILES + "${CMAKE_CURRENT_SOURCE_DIR}/include/future_helpers.h" + "${CMAKE_CURRENT_SOURCE_DIR}/include/container_helpers.h" + "${CMAKE_CURRENT_SOURCE_DIR}/include/thread_pool.h" + # GAUSSIAN SPLAT + "${CMAKE_CURRENT_SOURCE_DIR}/include/splat/splat.h" + "${CMAKE_CURRENT_SOURCE_DIR}/include/splat/splat_lod.h" + "${CMAKE_CURRENT_SOURCE_DIR}/include/splat/splat_block.h" + # NODE API FILES + "${CMAKE_CURRENT_SOURCE_DIR}/include/node/api_buffer.h" + "${CMAKE_CURRENT_SOURCE_DIR}/include/node/api_splat.h" + "${CMAKE_CURRENT_SOURCE_DIR}/include/node/api_spatial.h" + "${CMAKE_CURRENT_SOURCE_DIR}/include/node/api_thread_pool.h" + "${CMAKE_CURRENT_SOURCE_DIR}/include/node/api_webp.h" + "${CMAKE_CURRENT_SOURCE_DIR}/include/node/api_avif.h" +) +target_sources(${PROJECT_NAME} +PRIVATE + ${BINDING_SOURCE_FILES} + +PRIVATE FILE_SET binding_private_headers + TYPE HEADERS + BASE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/include" + FILES ${BINDING_HEADER_FILES} +) +target_link_libraries(${PROJECT_NAME} + PRIVATE cmake_js::node Threads::Threads Eigen3::Eigen nanoflann::nanoflann WebP::webp avif +) +set_target_properties( + ${PROJECT_NAME} PROPERTIES + PREFIX "" + OUTPUT_NAME "splat-transform" + SUFFIX ".node" + CXX_EXTENSIONS OFF + COMPILE_FEATURES cxx_std_20 + COMPILE_OPTIONS $<$:/Zc:__cplusplus> + LINK_OPTIONS $<$,$>>:-Wl,--exclude-libs,ALL> + COMPILE_DEFINITIONS "UNICODE;_UNICODE" + INTERPROCEDURAL_OPTIMIZATION ON + LIBRARY_OUTPUT_DIRECTORY ${BINDING_BINARY_DIR} + RUNTIME_OUTPUT_DIRECTORY ${BINDING_BINARY_DIR} + RUNTIME_OUTPUT_DIRECTORY_RELEASE ${BINDING_BINARY_DIR} + LIBRARY_OUTPUT_DIRECTORY_RELEASE ${BINDING_BINARY_DIR} +) diff --git a/packages/splat-transform-native/source/CMakePresets.json b/packages/splat-transform-native/source/CMakePresets.json new file mode 100644 index 0000000..0cf8f43 --- /dev/null +++ b/packages/splat-transform-native/source/CMakePresets.json @@ -0,0 +1,155 @@ +{ + "version": 10, + "cmakeMinimumRequired": { + "major": 3, + "minor": 31, + "patch": 0 + }, + "configurePresets": [ + { + "$comment": [ + "default build config for any env", + "needs: vcpkg", + "vcpkg install root should be in environments as VCPKG_ROOT" + ], + "name": "default", + "toolchainFile": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "cacheVariables": { + "CMAKE_PREFIX_PATH": "${sourceDir}/cmake" + } + }, + { + "name": "x64-windows-static", + "$comment": ["build config for x64 windows"], + "inherits": "default", + "cacheVariables": { + "VCPKG_TARGET_TRIPLET": "x64-windows-static" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "arm64-windows-static", + "$comment": ["build config for arm64 windows"], + "inherits": "default", + "cacheVariables": { + "VCPKG_TARGET_TRIPLET": "arm64-windows-static" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "x64-linux", + "$comment": ["build config for x64 linux"], + "inherits": "default", + "cacheVariables": { + "VCPKG_TARGET_TRIPLET": "x64-linux", + "CMAKE_MAKE_PROGRAM": "/usr/bin/ninja", + "CMAKE_C_COMPILER": "/usr/bin/cc", + "CMAKE_CXX_COMPILER": "/usr/bin/c++" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + }, + { + "name": "arm64-linux", + "$comment": ["build config for arm64 linux"], + "inherits": "default", + "cacheVariables": { + "VCPKG_TARGET_TRIPLET": "arm64-linux", + "CMAKE_ASM_NASM_COMPILER": "/usr/bin/nasm", + "CMAKE_MAKE_PROGRAM": "/usr/bin/ninja", + "CMAKE_C_COMPILER": "/usr/bin/cc", + "CMAKE_CXX_COMPILER": "/usr/bin/c++" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + }, + { + "name": "arm64-osx", + "$comment": ["build config for arm64 osx"], + "inherits": "default", + "cacheVariables": { + "VCPKG_TARGET_TRIPLET": "arm64-osx", + "CMAKE_MAKE_PROGRAM": "/opt/homebrew/bin/ninja", + "CMAKE_ASM_NASM_COMPILER": "/opt/homebrew/bin/nasm", + "CMAKE_C_COMPILER": "/usr/bin/cc", + "CMAKE_CXX_COMPILER": "/usr/bin/c++" + }, + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + } + } + ], + "buildPresets": [ + { + "name": "default", + "configurePreset": "default", + "jobs": 8 + }, + { + "name": "x64-windows-static", + "inherits": "default", + "configurePreset": "x64-windows-static", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "arm64-windows-static", + "inherits": "default", + "configurePreset": "arm64-windows-static", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "x64-linux", + "inherits": "default", + "configurePreset": "x64-linux", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + }, + { + "name": "arm64-linux", + "inherits": "default", + "configurePreset": "arm64-linux", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + } + }, + { + "name": "arm64-osx", + "inherits": "default", + "configurePreset": "arm64-osx", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + } + } + ] +} diff --git a/packages/splat-transform-native/source/build.js b/packages/splat-transform-native/source/build.js new file mode 100644 index 0000000..e66a157 --- /dev/null +++ b/packages/splat-transform-native/source/build.js @@ -0,0 +1,47 @@ +import path from 'node:path'; +import { createRequire } from 'node:module'; +import yargs from 'yargs/yargs'; +import { hideBin } from 'yargs/helpers'; +import { spawnProcess } from '@internal/utils/process.js'; + +const __dirname = import.meta.dirname; +const require = createRequire(import.meta.url); + +await yargs(hideBin(process.argv)) + .scriptName('build') + .showHelpOnFail(false) + .strict() + .command({ + command: ['build', '$0'], + builder(argv) { + argv.option('preset', { + describe: 'CMake preset', + default: 'default', + }).option('target', { + describe: 'Platform target', + demandOption: true, + }); + }, + async handler(argv) { + await spawnProcess( + 'node', + [ + require.resolve('cmake-js/bin/cmake-js'), + 'build', + `--CDBINDING_BINARY_DIR=${path.resolve(`../splat-transform-${argv.target}`)}`, + '--', + '--preset', + argv.preset, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + FORCE_COLOR: 1, + PATH: path.join(__dirname, '.`/node_modules/.bin') + path.delimiter + process.env.PATH, + }, + }, + ).promise; + }, + }) + .parseAsync(); diff --git a/packages/splat-transform-native/source/cmake/share/cmake-js/cmake-js-config.cmake b/packages/splat-transform-native/source/cmake/share/cmake-js/cmake-js-config.cmake new file mode 100644 index 0000000..2c42122 --- /dev/null +++ b/packages/splat-transform-native/source/cmake/share/cmake-js/cmake-js-config.cmake @@ -0,0 +1,25 @@ +# cmake-js config for find_package +add_library(cmake_js::node INTERFACE IMPORTED) + +if(MSVC) + if(CMAKE_JS_NODELIB_DEF AND CMAKE_JS_NODELIB_TARGET) + # Generate node.lib + add_custom_command(OUTPUT ${CMAKE_JS_NODELIB_TARGET} + COMMAND ${CMAKE_AR} /def:${CMAKE_JS_NODELIB_DEF} /out:${CMAKE_JS_NODELIB_TARGET} ${CMAKE_STATIC_LINKER_FLAGS} + ) + add_custom_target(node_lib DEPENDS ${CMAKE_JS_NODELIB_TARGET}) + target_link_libraries(cmake_js::node INTERFACE ${CMAKE_JS_NODELIB_TARGET}) + add_dependencies(cmake_js::node node_lib) + endif() +endif() + +if(CMAKE_SYSTEM_NAME STREQUAL "Windows") + target_link_libraries(cmake_js::node INTERFACE "delayimp.lib") +endif() + +set_target_properties(cmake_js::node PROPERTIES + INTERFACE_COMPILE_FEATURES cxx_std_14 + INTERFACE_COMPILE_DEFINITIONS "NAPI_VERSION=8" + INTERFACE_INCLUDE_DIRECTORIES "${CMAKE_JS_INC}" + INTERFACE_SOURCES "${CMAKE_JS_SRC}" +) diff --git a/packages/splat-transform-native/source/include/container_helpers.h b/packages/splat-transform-native/source/include/container_helpers.h new file mode 100644 index 0000000..4274a82 --- /dev/null +++ b/packages/splat-transform-native/source/include/container_helpers.h @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include + +namespace helpers::container { + +template +concept range_of = std::ranges::range && std::same_as, V>; + +template + requires std::same_as, std::ranges::range_value_t> && + requires(T&& dst, U&& src) { +#if __cpp_lib_containers_ranges + dst.append_range(std::forward(src)); +#else + dst.insert(dst.end(), src.begin(), src.end()); +#endif + } +inline void append_range(T&& dst, U&& src) { +#if __cpp_lib_containers_ranges + dst.append_range(std::forward(src)); +#else + dst.insert(dst.end(), src.begin(), src.end()); +#endif +} + +template + requires requires(T&& dst, std::initializer_list>&& src) { +#if __cpp_lib_containers_ranges + dst.append_range(std::move(src)); +#else + dst.insert(dst.end(), src.begin(), src.end()); +#endif + } +inline void append_range(T&& dst, std::initializer_list>&& src) { + append_range>&&>(std::forward(dst), std::move(src)); +} +} // namespace helpers::container diff --git a/packages/splat-transform-native/source/include/future_helpers.h b/packages/splat-transform-native/source/include/future_helpers.h new file mode 100644 index 0000000..0301e71 --- /dev/null +++ b/packages/splat-transform-native/source/include/future_helpers.h @@ -0,0 +1,135 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace helpers::future { +namespace detail { +template +struct future_value; + +template +struct future_value> { + using type = T; +}; + +template +concept future_range = std::ranges::range && requires { + typename future_value>>::type; +}; + +template +using future_range_value_t = future_value>>::type; +} // namespace detail + +struct FailurePolicyDrainAll; +// Stops draining after the first observed failure. This does not cancel remaining +// work; use only when unfinished tasks do not depend on this stack frame. +struct FailurePolicyStopOnFirstFailure; + +template +concept future_failure_policy = std::same_as || std::same_as; + +template + requires std::same_as, void> +void drain_futures(T&& futures) { + std::exception_ptr first_exception; + for (auto& future : futures) { + try { + future.get(); + } catch (...) { + if (!first_exception) { + first_exception = std::current_exception(); + } + + if constexpr (std::is_same_v) { + break; + } + } + } + + if (first_exception) { + std::rethrow_exception(first_exception); + } +} + +template + requires std::same_as, void> && + std::invocable +void drain_futures(T&& futures, F&& f) { + std::exception_ptr first_exception; + size_t index = 0; + for (auto& future : futures) { + try { + future.get(); + std::invoke(f, index); + } catch (...) { + if (!first_exception) { + first_exception = std::current_exception(); + } + + if constexpr (std::is_same_v) { + break; + } + } + index++; + } + + if (first_exception) { + std::rethrow_exception(first_exception); + } +} + +template + requires(!std::same_as, void>) && + std::invocable&&> +void drain_futures(T&& futures, F&& f) { + std::exception_ptr first_exception; + for (auto& future : futures) { + try { + std::invoke(f, future.get()); + } catch (...) { + if (!first_exception) { + first_exception = std::current_exception(); + } + if constexpr (std::is_same_v) { + break; + } + } + } + + if (first_exception) { + std::rethrow_exception(first_exception); + } +} + +template + requires(!std::same_as, void>) && + std::invocable&&, size_t> +void drain_futures(T&& futures, F&& f) { + std::exception_ptr first_exception; + size_t index = 0; + for (auto& future : futures) { + try { + std::invoke(f, future.get(), index); + } catch (...) { + if (!first_exception) { + first_exception = std::current_exception(); + } + + if constexpr (std::is_same_v) { + break; + } + } + index++; + } + + if (first_exception) { + std::rethrow_exception(first_exception); + } +} +} // namespace helpers::future diff --git a/packages/splat-transform-native/source/include/node/api_avif.h b/packages/splat-transform-native/source/include/node/api_avif.h new file mode 100644 index 0000000..a588f24 --- /dev/null +++ b/packages/splat-transform-native/source/include/node/api_avif.h @@ -0,0 +1,9 @@ +#pragma once +#include + +namespace node_api::imaging { +Napi::Value avif_encode_rgba(const Napi::CallbackInfo& info); +Napi::Value avif_encode_rgba_batched(const Napi::CallbackInfo& info); +Napi::Value avif_decode_rgba(const Napi::CallbackInfo& info); +Napi::Value avif_decode_rgba_batched(const Napi::CallbackInfo& info); +} // namespace node_api::imaging diff --git a/packages/splat-transform-native/source/include/node/api_buffer.h b/packages/splat-transform-native/source/include/node/api_buffer.h new file mode 100644 index 0000000..24924c0 --- /dev/null +++ b/packages/splat-transform-native/source/include/node/api_buffer.h @@ -0,0 +1,92 @@ +#pragma once +#include +#include +#include +#include + +namespace node_api::buffer { +template> +class UniqueBufferFinalizer { +public: + UniqueBufferFinalizer() noexcept = default; + UniqueBufferFinalizer(const UniqueBufferFinalizer& other) = delete; + UniqueBufferFinalizer(UniqueBufferFinalizer&& other) noexcept = default; + UniqueBufferFinalizer(std::unique_ptr&& ptr) noexcept : ptr(std::move(ptr)) {} + UniqueBufferFinalizer(T* ptr) noexcept : ptr(ptr) {} + void operator()(Napi::Env env, T* data) noexcept {} + + inline static Napi::Buffer make_buffer(Napi::Env& env, std::unique_ptr&& ptr, size_t size) noexcept { + auto finalizer = UniqueBufferFinalizer(std::move(ptr)); + auto data_ptr = finalizer.ptr.get(); + return Napi::Buffer::NewOrCopy(env, data_ptr, size, std::move(finalizer)); + } + +protected: + std::unique_ptr ptr; +}; + +template>> +class UniqueVecBufferFinalizer : public UniqueBufferFinalizer, D> { +public: + void operator()(Napi::Env env, T* data) noexcept {} + + template + inline static Napi::Buffer make_buffer(Napi::Env& env, U&& data) noexcept { + auto finalizer = UniqueVecBufferFinalizer(std::forward(data)); + auto data_ptr = finalizer.ptr->data(); + auto data_size = finalizer.ptr->size(); + return Napi::Buffer::NewOrCopy(env, data_ptr, data_size, std::move(finalizer)); + } +}; + +template +class SharedBufferFinalizer { +public: + SharedBufferFinalizer() noexcept = default; + SharedBufferFinalizer(const SharedBufferFinalizer& other) noexcept = default; + SharedBufferFinalizer(SharedBufferFinalizer&& other) noexcept = default; + SharedBufferFinalizer(const std::shared_ptr& ptr) noexcept : ptr(ptr) {} + SharedBufferFinalizer(std::shared_ptr&& ptr) noexcept : ptr(std::move(ptr)) {} + void operator()(Napi::Env env, T* data) noexcept {} + + template + requires std::is_same_v, std::shared_ptr> + inline static Napi::Buffer make_buffer(Napi::Env& env, U&& ptr, size_t size) noexcept { + auto finalizer = SharedBufferFinalizer(std::forward(ptr)); + auto data_ptr = finalizer.ptr.get(); + return Napi::Buffer::NewOrCopy(env, data_ptr, size, std::move(finalizer)); + } + + template + requires std::is_same_v, std::shared_ptr> + inline static Napi::Buffer make_buffer(Napi::Env& env, U&& ptr, size_t offset, size_t size) noexcept { + auto finalizer = SharedBufferFinalizer(std::forward(ptr)); + auto data_ptr = finalizer.ptr.get(); + return Napi::Buffer::NewOrCopy(env, data_ptr + offset, size, std::move(finalizer)); + } + +protected: + std::shared_ptr ptr; +}; + +template +class SharedVecBufferFinalizer : public SharedBufferFinalizer> { +public: + void operator()(Napi::Env env, T* data) noexcept {} + + template + inline static Napi::Buffer make_buffer(Napi::Env& env, U&& data) noexcept { + auto finalizer = SharedVecBufferFinalizer(std::forward(data)); + auto data_ptr = finalizer.ptr->data(); + auto data_size = finalizer.ptr->size(); + return Napi::Buffer::NewOrCopy(env, data_ptr, data_size, std::move(finalizer)); + } + + template + inline static Napi::Buffer make_buffer(Napi::Env& env, U&& data, size_t offset, size_t size) noexcept { + auto finalizer = SharedVecBufferFinalizer(std::forward(data)); + auto data_ptr = finalizer.ptr->data(); + return Napi::Buffer::NewOrCopy(env, data_ptr + offset, size, std::move(finalizer)); + } +}; +} // namespace node_api::buffer diff --git a/packages/splat-transform-native/source/include/node/api_spatial.h b/packages/splat-transform-native/source/include/node/api_spatial.h new file mode 100644 index 0000000..51cc6b1 --- /dev/null +++ b/packages/splat-transform-native/source/include/node/api_spatial.h @@ -0,0 +1,6 @@ +#pragma once +#include + +namespace node_api::spatial { +Napi::Value cluster_average(const Napi::CallbackInfo& info); +} diff --git a/packages/splat-transform-native/source/include/node/api_splat.h b/packages/splat-transform-native/source/include/node/api_splat.h new file mode 100644 index 0000000..87c9b72 --- /dev/null +++ b/packages/splat-transform-native/source/include/node/api_splat.h @@ -0,0 +1,7 @@ +#pragma once +#include + +namespace node_api::splat { +Napi::Value generate_lod(const Napi::CallbackInfo& info); +Napi::Value split(const Napi::CallbackInfo& info); +} // namespace node_api::splat diff --git a/packages/splat-transform-native/source/include/node/api_thread_pool.h b/packages/splat-transform-native/source/include/node/api_thread_pool.h new file mode 100644 index 0000000..1ba678a --- /dev/null +++ b/packages/splat-transform-native/source/include/node/api_thread_pool.h @@ -0,0 +1,26 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace node_api::threading { +class ThreadPool : public Napi::ObjectWrap { +public: + static Napi::FunctionReference Init(Napi::Env env, Napi::Object exports); + + ThreadPool(const Napi::CallbackInfo& info); + ::threading::ThreadPool& impl() noexcept; + + ThreadPool() = delete; + +private: + static size_t calc_thread_count(const Napi::CallbackInfo& info); + Napi::Value thread_count(const Napi::CallbackInfo& info); + Napi::Value task_count(const Napi::CallbackInfo& info); + + ::threading::ThreadPool impl_; +}; +} // namespace node_api::threading diff --git a/packages/splat-transform-native/source/include/node/api_webp.h b/packages/splat-transform-native/source/include/node/api_webp.h new file mode 100644 index 0000000..16134fc --- /dev/null +++ b/packages/splat-transform-native/source/include/node/api_webp.h @@ -0,0 +1,8 @@ +#pragma once +#include + +namespace node_api::imaging { +Napi::Value webp_encode_rgba_lossless(const Napi::CallbackInfo& info); +Napi::Value webp_encode_rgba(const Napi::CallbackInfo& info); +Napi::Value webp_decode_rgba(const Napi::CallbackInfo& info); +} // namespace node_api::imaging diff --git a/packages/splat-transform-native/source/include/splat/splat.h b/packages/splat-transform-native/source/include/splat/splat.h new file mode 100644 index 0000000..fc67dbb --- /dev/null +++ b/packages/splat-transform-native/source/include/splat/splat.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include +#include +#include + +namespace splat { +class SH { +public: + SH() noexcept; + SH(size_t size); + SH(const SH& other); + SH(SH&& other) noexcept; + + SH& operator=(const SH& other); + SH& operator=(SH&& other) noexcept; + + void swap(SH& other) noexcept; + + SH& set_zero() noexcept; + SH& add_multiplied(const SH& other, float scalar) noexcept; + + const float& operator[](size_t index) const; + float& operator[](size_t index); + + float* data() noexcept; + size_t size() const noexcept; + + ~SH() noexcept; + +private: + void reset() noexcept; + + size_t size_; + std::unique_ptr ptr; +}; + +struct Gaussian { + Eigen::Vector3f mean; + Eigen::Vector3f scale; + Eigen::Vector4f rotation; + Eigen::Matrix3f covariance; + SH sh; + float opacity; + Eigen::AlignedBox3f bounding_box; + + void compute_covariance(); + void decompose_covariance(); + void compute_bounding_box(float k = 3.0f); + float area() const; +}; + +struct Splat { + std::vector gaussians; + Eigen::AlignedBox3f bounding_box; + + void compute_bounding_box(); + void compute_compact_bounding_box(); +}; +} // namespace splat diff --git a/packages/splat-transform-native/source/include/splat/splat_block.h b/packages/splat-transform-native/source/include/splat/splat_block.h new file mode 100644 index 0000000..5252852 --- /dev/null +++ b/packages/splat-transform-native/source/include/splat/splat_block.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include +#include + +namespace splat::block { +namespace detail { +std::vector split(const Splat& splat, size_t max_block_size); +} // namespace detail + +template + requires std::is_same_v, Splat> +std::vector split(T&& input, double precision) { + auto max_block_size = static_cast(static_cast(input.gaussians.size()) * precision); + return detail::split(input, max_block_size); +} +} // namespace splat::block diff --git a/packages/splat-transform-native/source/include/splat/splat_lod.h b/packages/splat-transform-native/source/include/splat/splat_lod.h new file mode 100644 index 0000000..07b04af --- /dev/null +++ b/packages/splat-transform-native/source/include/splat/splat_lod.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include +#include + +namespace splat::lod { +namespace detail { +Splat reduce_gaussians(size_t id, const Splat& splat, size_t target_count, float scale_boost, size_t max_step); +} + +struct SplatLod { + std::vector splats; + std::vector levels; +}; + +struct SplatLevelParameters { + float precision; + float scale_boost; +}; + +template + requires requires(T) { + requires helpers::container::range_of; + } +inline void generate_lod(size_t id, SplatLod& lod, T&& parameters, size_t min_size, size_t max_step) { + auto base_count = lod.splats.back().gaussians.size(); + for (auto& parameter : parameters) { + auto target_count = std::max(static_cast(std::floor(static_cast(base_count) * parameter.precision)), 1); + auto& current = lod.splats.back(); + if (current.gaussians.size() > min_size && target_count < current.gaussians.size()) { + auto generated = detail::reduce_gaussians( + id, + current, + target_count, + parameter.scale_boost, + max_step); + lod.splats.push_back(std::move(generated)); + } + lod.levels.push_back(lod.splats.size() - 1); + } +} +} // namespace splat::lod diff --git a/packages/splat-transform-native/source/include/thread_pool.h b/packages/splat-transform-native/source/include/thread_pool.h new file mode 100644 index 0000000..1ddf1aa --- /dev/null +++ b/packages/splat-transform-native/source/include/thread_pool.h @@ -0,0 +1,122 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace threading { +class ThreadPool { +public: + ThreadPool(size_t thread_count = std::thread::hardware_concurrency()); + ThreadPool(ThreadPool&& other) noexcept; + + template, std::decay_t...>> + requires std::invocable, std::decay_t...> + std::future submit_task(F&& f, Args&&... args); + size_t thread_count() const noexcept; + size_t task_count() const noexcept; + ThreadPool& operator=(ThreadPool&& other) noexcept; + void stop() noexcept; + ~ThreadPool() noexcept; + + ThreadPool(const ThreadPool& other) = delete; + ThreadPool& operator=(const ThreadPool& other) = delete; + +private: +#ifdef __cpp_lib_move_only_function + using Task = std::move_only_function; +#else + using Task = std::function; +#endif + + struct Worker { + std::atomic stopped { false }; + std::thread thread; + + void stop() noexcept; + void clean_up() noexcept; + ~Worker() noexcept; + }; + + struct State { + std::vector> workers; + std::queue tasks; + mutable std::shared_mutex queue_mutex; + mutable std::shared_mutex worker_mutex; + std::condition_variable_any cv; + std::atomic stopped { false }; + + void stop() noexcept; + ~State() noexcept; + }; + + static void worker_loop(State* state, Worker* worker); + + std::unique_ptr state; +}; + +template + requires std::invocable, std::decay_t...> +std::future ThreadPool::submit_task(F&& f, Args&&... args) { + auto* state = this->state.get(); + if (state == nullptr || state->stopped.load(std::memory_order_relaxed)) { + throw std::runtime_error("Cannot submit tasks to stopped thread pool"); + } +#ifdef __cpp_lib_move_only_function + auto promise = std::promise(); + auto future = promise.get_future(); + { + auto lk = std::lock_guard(state->queue_mutex); + if (state->stopped.load(std::memory_order_relaxed)) { + throw std::runtime_error("Cannot submit tasks to stopped thread pool"); + } + state->tasks.emplace([f = std::forward(f), ... args = std::forward(args), promise = std::move(promise)]() mutable noexcept -> void { + try { + if constexpr (std::is_void_v) { + std::invoke(std::move(f), std::move(args)...); + promise.set_value(); + } else { + promise.set_value(std::invoke(std::move(f), std::move(args)...)); + } + } catch (...) { + promise.set_exception(std::current_exception()); + } + }); + } +#else + auto promise = std::make_shared>(); + auto future = promise->get_future(); + { + auto lk = std::lock_guard(state->queue_mutex); + if (state->stopped.load(std::memory_order_relaxed)) { + throw std::runtime_error("Cannot submit tasks to stopped thread pool"); + } + state->tasks.emplace([f = std::forward(f), ... args = std::forward(args), promise = promise]() noexcept -> void { + try { + if constexpr (std::is_void_v) { + std::invoke(std::move(f), std::move(args)...); + promise->set_value(); + } else { + promise->set_value(std::invoke(std::move(f), std::move(args)...)); + } + } catch (...) { + promise->set_exception(std::current_exception()); + } + }); + } +#endif + state->cv.notify_one(); + return future; +} +} // namespace threading diff --git a/packages/splat-transform-native/source/package.json b/packages/splat-transform-native/source/package.json new file mode 100644 index 0000000..edb67ad --- /dev/null +++ b/packages/splat-transform-native/source/package.json @@ -0,0 +1,23 @@ +{ + "name": "@internal/splat-transform-native-source", + "version": "0.0.1", + "private": true, + "type": "module", + "dependencies": { + "cmake-js": "^8.0.0", + "node-addon-api": "^8.9.0", + "yargs": "^18.0.0" + }, + "scripts": { + "build:native:win32-arm64": "node ./build.js build --preset arm64-windows-static --target win32-arm64-msvc", + "build:native:win32-x64": "node ./build.js build --preset x64-windows-static --target win32-x64-msvc", + "build:native:linux-arm64-gnu": "node ./build.js build --preset arm64-linux --target linux-arm64-gnu", + "build:native:linux-x64-gnu": "node ./build.js build --preset x64-linux --target linux-x64-gnu", + "build:native:darwin-arm64": "node ./build.js build --preset arm64-osx --target darwin-arm64" + }, + "binary": { + "napi_versions": [ + 8 + ] + } +} diff --git a/packages/splat-transform-native/source/src/node/api_avif.cpp b/packages/splat-transform-native/source/src/node/api_avif.cpp new file mode 100644 index 0000000..1670cc6 --- /dev/null +++ b/packages/splat-transform-native/source/src/node/api_avif.cpp @@ -0,0 +1,301 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +class RawData { +public: + RawData() noexcept : data_(AVIF_DATA_EMPTY) {} + RawData(RawData&& other) noexcept : data_(other.data_) { + other.data_ = AVIF_DATA_EMPTY; + } + RawData(avifRWData&& data) noexcept : data_(data) { + data = AVIF_DATA_EMPTY; + } + RawData(const RawData& other) = delete; + void operator()(Napi::Env env, uint8_t* data) noexcept {} + + RawData& operator=(RawData&& other) noexcept { + if (this != &other) { + if (this->data_.data != nullptr) { + avifRWDataFree(&this->data_); + } + this->data_ = other.data_; + other.data_ = AVIF_DATA_EMPTY; + } + return *this; + } + + RawData& operator=(const RawData& other) = delete; + + avifRWData& data() { + return this->data_; + } + + const avifRWData& data() const { + return this->data_; + } + + Napi::Buffer make_buffer(Napi::Env& env) { + auto ptr = this->data_.data; + auto size = this->data_.size; + return Napi::Buffer::NewOrCopy(env, ptr, size, std::move(*this)); + } + + ~RawData() { + if (this->data_.data != nullptr) { + avifRWDataFree(&this->data_); + } + } + +private: + avifRWData data_; +}; + +class RGBImageData { +public: + RGBImageData() noexcept : data_({ 0 }) {} + RGBImageData(RGBImageData&& other) noexcept : data_(other.data_) { + other.data_ = { 0 }; + } + RGBImageData(avifRGBImage&& data) noexcept : data_(data) { + data = { 0 }; + } + RGBImageData(const RGBImageData& other) = delete; + void operator()(Napi::Env env, uint8_t* data) noexcept {} + + RGBImageData& operator=(RGBImageData&& other) noexcept { + if (this != &other) { + if (this->data_.pixels != nullptr) { + avifRGBImageFreePixels(&this->data_); + } + this->data_ = other.data_; + other.data_ = { 0 }; + } + return *this; + } + + RGBImageData& operator=(const RGBImageData& other) = delete; + + avifRGBImage& data() { + return this->data_; + } + + const avifRGBImage& data() const { + return this->data_; + } + + Napi::Buffer make_buffer(Napi::Env& env) { + auto ptr = this->data_.pixels; + auto size = this->data_.rowBytes * this->data_.height; + return Napi::Buffer::NewOrCopy(env, ptr, size, std::move(*this)); + } + + ~RGBImageData() { + if (this->data_.pixels != nullptr) { + avifRGBImageFreePixels(&this->data_); + } + } + +private: + avifRGBImage data_; +}; + +RawData avif_encode_rga_impl(std::span pixels, int32_t width, int32_t height, int32_t quality, int32_t max_threads) { + avif::EncoderPtr encoder = nullptr; + avifRGBImage rgb; + std::memset(&rgb, 0, sizeof(rgb)); + avif::ImagePtr image(avifImageCreate(width, height, 8, AVIF_PIXEL_FORMAT_YUV444)); + + avifRGBImageSetDefaults(&rgb, image.get()); + rgb.pixels = pixels.data(); + rgb.rowBytes = 4 * rgb.width; + avifResult convertResult = avifImageRGBToYUV(image.get(), &rgb); + + if (convertResult != AVIF_RESULT_OK) { + throw std::runtime_error("Failed to convert to YUV(A)"); + } + + encoder.reset(avifEncoderCreate()); + if (encoder == nullptr) { + throw std::runtime_error("Out of memory"); + } + + encoder->quality = quality; + encoder->qualityAlpha = AVIF_QUALITY_LOSSLESS; + encoder->maxThreads = max_threads; + + avifResult addImageResult = avifEncoderAddImage(encoder.get(), image.get(), 1, AVIF_ADD_IMAGE_FLAG_SINGLE); + + if (addImageResult != AVIF_RESULT_OK) { + throw std::runtime_error("Failed to add image to encoder"); + } + + RawData avifOutput; + avifResult finishResult = avifEncoderFinish(encoder.get(), &avifOutput.data()); + + if (finishResult != AVIF_RESULT_OK) { + throw std::runtime_error("Failed to finish encode"); + } + + return avifOutput; +} + +RGBImageData avif_decode_rgba_impl(std::span data, int32_t max_threads) { + avif::DecoderPtr decoder(avifDecoderCreate()); + + if (decoder == nullptr) { + throw std::runtime_error("Out of memory"); + } + + decoder->maxThreads = max_threads; + + avifResult result = avifDecoderSetIOMemory(decoder.get(), data.data(), data.size()); + + if (result != AVIF_RESULT_OK) { + throw std::runtime_error("Cannot set IO on avifDecoder"); + } + + result = avifDecoderParse(decoder.get()); + + if (result != AVIF_RESULT_OK) { + throw std::runtime_error("Failed to decode image"); + } + + result = avifDecoderNextImage(decoder.get()); + + if (result != AVIF_RESULT_OK) { + throw std::runtime_error("Failed to get image"); + } + + RGBImageData rgb; + avifRGBImageSetDefaults(&rgb.data(), decoder->image); + result = avifRGBImageAllocatePixels(&rgb.data()); + + if (result != AVIF_RESULT_OK) { + throw std::runtime_error("Allocation of RGB samples failed"); + } + + result = avifImageYUVToRGB(decoder->image, &rgb.data()); + + if (result != AVIF_RESULT_OK) { + throw std::runtime_error("Conversion from YUV failed"); + } + + return rgb; +} +} // namespace + +namespace node_api::imaging { +Napi::Value avif_encode_rgba(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 4 || !info[0].IsBuffer() || !info[1].IsNumber() || !info[2].IsNumber() || !info[3].IsNumber()) { + Napi::TypeError::New(env, "Wrong Arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + auto buffer = info[0].As>(); + auto width = info[1].As().Int32Value(); + auto height = info[2].As().Int32Value(); + auto quality = info[3].As().Int32Value(); + + return avif_encode_rga_impl( + std::span(buffer.Data(), buffer.Length()), + width, height, + quality, static_cast(std::thread::hardware_concurrency())) + .make_buffer(env); +} +Napi::Value avif_encode_rgba_batched(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 2 || !info[0].IsArray() || !info[1].IsObject()) { + Napi::TypeError::New(env, "Wrong Arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + + constexpr auto max_thread = 16; + auto& pool = node_api::threading::ThreadPool::Unwrap(info[1].As())->impl(); + auto thread_count = pool.thread_count(); + auto inputs = info[0].As(); + auto input_count = inputs.Length(); + auto outputs = Napi::Array::New(env, input_count); + + { + auto futures = std::vector>(); + futures.reserve(inputs.Length()); + for (auto i = 0; i < input_count; i++) { + auto input = inputs[i].AsValue().As(); + auto buffer = input.Get("data").As>(); + auto width = input.Get("width").As().Int32Value(); + auto height = input.Get("height").As().Int32Value(); + auto quality = input.Get("quality").As().Int32Value(); + futures.push_back(pool.submit_task(avif_encode_rga_impl, std::span(buffer.Data(), buffer.Length()), width, height, quality, max_thread)); + } + + helpers::future::drain_futures(futures, [&](RawData&& data, size_t i) { + outputs[i] = data.make_buffer(env); + }); + } + + return outputs; +} +Napi::Value avif_decode_rgba(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 1 || !info[0].IsBuffer()) { + Napi::TypeError::New(env, "Wrong Arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + auto buffer = info[0].As>(); + + auto&& rgb = avif_decode_rgba_impl(std::span(buffer.Data(), buffer.Length()), + static_cast(std::thread::hardware_concurrency())); + + auto object = Napi::Object::New(env); + { + object.Set("width", Napi::Number::New(env, rgb.data().width)); + object.Set("height", Napi::Number::New(env, rgb.data().height)); + object.Set("data", rgb.make_buffer(env)); + } + return object; +} + +Napi::Value avif_decode_rgba_batched(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 2 || !info[0].IsArray() || !info[1].IsObject()) { + Napi::TypeError::New(env, "Wrong Arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + + constexpr auto max_thread = 16; + auto& pool = node_api::threading::ThreadPool::Unwrap(info[1].As())->impl(); + auto thread_count = pool.thread_count(); + auto inputs = info[0].As(); + auto input_count = inputs.Length(); + auto outputs = Napi::Array::New(env, input_count); + + { + auto futures = std::vector>(); + futures.reserve(inputs.Length()); + for (auto i = 0; i < input_count; i++) { + auto input = inputs[i].AsValue().As>(); + futures.push_back(pool.submit_task(avif_decode_rgba_impl, std::span(input.Data(), input.Length()), max_thread)); + } + + helpers::future::drain_futures(futures, [&](RGBImageData&& rgb, size_t i) { + auto object = Napi::Object::New(env); + object.Set("width", Napi::Number::New(env, rgb.data().width)); + object.Set("height", Napi::Number::New(env, rgb.data().height)); + object.Set("data", rgb.make_buffer(env)); + outputs[i] = object; + }); + } + + return outputs; +} +} // namespace node_api::imaging diff --git a/packages/splat-transform-native/source/src/node/api_spatial.cpp b/packages/splat-transform-native/source/src/node/api_spatial.cpp new file mode 100644 index 0000000..d8f95b5 --- /dev/null +++ b/packages/splat-transform-native/source/src/node/api_spatial.cpp @@ -0,0 +1,150 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +struct PartialAverage { + std::unique_ptr sums; + std::unique_ptr counts; +}; +} // namespace + +namespace node_api::spatial { +Napi::Value cluster_average(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 5 || !info[0].IsArray() || !info[1].IsBuffer() || !info[2].IsNumber() || !info[3].IsArray() || !info[4].IsObject()) { + Napi::TypeError::New(env, "Wrong Arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + + auto input_table = info[0].As(); + auto input_labels = info[1].As>(); + auto cluster_count = static_cast(info[2].As().Uint32Value()); + auto output_table = info[3].As(); + auto thread_pool_wrapper = node_api::threading::ThreadPool::Unwrap(info[4].As()); + + auto col_size = input_table.Length(); + + auto data_table = std::vector>(); + auto output = std::vector>(); + data_table.reserve(col_size); + output.reserve(col_size); + + for (auto i = 0; i < col_size; i++) { + auto data = input_table.Get(i).As>(); + auto out = output_table.Get(i).As>(); + data_table.emplace_back(data.Data(), data.Length()); + output.emplace_back(out.Data(), out.Length()); + } + + auto labels = std::span(input_labels.Data(), input_labels.Length()); + auto row_size = data_table[0].size(); + + auto& pool = thread_pool_wrapper->impl(); + auto partials = std::vector(); + auto fallback_rows = std::vector(cluster_count, 0); + if (row_size > 0) { + std::random_device random_device; + auto seed = (static_cast(random_device()) << 32) ^ static_cast(random_device()); + auto rng = std::mt19937_64(seed); + auto row_distribution = std::uniform_int_distribution(0, row_size - 1); + for (auto& row_index : fallback_rows) { + row_index = row_distribution(rng); + } + } + + auto process_rows = [&](size_t begin, size_t end) -> PartialAverage { + auto partial = PartialAverage { + .sums = std::make_unique(cluster_count * col_size), + .counts = std::make_unique(cluster_count), + }; + for (auto row_index = begin; row_index < end; row_index++) { + auto cluster_index = labels[row_index]; + if (cluster_index >= cluster_count) { + continue; + } + + partial.counts[cluster_index]++; + auto sum_offset = static_cast(cluster_index) * col_size; + for (auto column_index = 0; column_index < col_size; column_index++) { + partial.sums[sum_offset + column_index] += data_table[column_index][row_index]; + } + } + return partial; + }; + + auto reduce_clusters = [&](size_t begin, size_t end) { + for (auto cluster_index = begin; cluster_index < end; cluster_index++) { + size_t count = 0; + for (auto column_index = 0; column_index < col_size; column_index++) { + output[column_index][cluster_index] = 0.0f; + } + + auto sum_offset = cluster_index * col_size; + for (auto& partial : partials) { + count += partial.counts[cluster_index]; + for (auto column_index = 0; column_index < col_size; column_index++) { + output[column_index][cluster_index] += partial.sums[sum_offset + column_index]; + } + } + + if (count == 0) { + auto row_index = fallback_rows[cluster_index]; + for (auto column_index = 0; column_index < col_size; column_index++) { + output[column_index][cluster_index] = data_table[column_index][row_index]; + } + } else { + auto inv_count = 1.0f / static_cast(count); + for (auto column_index = 0; column_index < col_size; column_index++) { + output[column_index][cluster_index] *= inv_count; + } + } + } + }; + + { + size_t used_thread_count = std::min(pool.thread_count(), row_size); + + auto row_tasks = std::vector>(); + row_tasks.reserve(used_thread_count); + + auto rows_per_thread = (row_size + used_thread_count - 1) / used_thread_count; + // process rows, calculate sum per thread. + for (size_t thread_index = 0; thread_index < used_thread_count; thread_index++) { + auto begin = thread_index * rows_per_thread; + auto end = std::min(begin + rows_per_thread, row_size); + row_tasks.push_back(pool.submit_task(process_rows, begin, end)); + } + + partials.reserve(row_tasks.size()); + helpers::future::drain_futures(row_tasks, [&](PartialAverage&& data) { + partials.push_back(std::move(data)); + }); + } + + { + size_t used_thread_count = std::min(pool.thread_count(), cluster_count); + auto tasks = std::vector>(); + tasks.reserve(used_thread_count); + auto clusters_per_thread = (cluster_count + used_thread_count - 1) / used_thread_count; + // reduce threaded cluster result + for (size_t thread_index = 0; thread_index < used_thread_count; thread_index++) { + auto begin = thread_index * clusters_per_thread; + auto end = std::min(begin + clusters_per_thread, cluster_count); + tasks.push_back(pool.submit_task(reduce_clusters, begin, end)); + } + helpers::future::drain_futures(tasks); + } + + return env.Null(); +} +} // namespace node_api::spatial diff --git a/packages/splat-transform-native/source/src/node/api_splat.cpp b/packages/splat-transform-native/source/src/node/api_splat.cpp new file mode 100644 index 0000000..a286c54 --- /dev/null +++ b/packages/splat-transform-native/source/src/node/api_splat.cpp @@ -0,0 +1,357 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +enum SplatTable { + SPLAT_TABLE_MEAN_X = 0, + SPLAT_TABLE_MEAN_Y = 1, + SPLAT_TABLE_MEAN_Z = 2, + SPLAT_TABLE_SCALE_X = 3, + SPLAT_TABLE_SCALE_Y = 4, + SPLAT_TABLE_SCALE_Z = 5, + SPLAT_TABLE_QUAT_X = 6, + SPLAT_TABLE_QUAT_Y = 7, + SPLAT_TABLE_QUAT_Z = 8, + SPLAT_TABLE_QUAT_W = 9, + SPLAT_TABLE_COLOR_R = 10, + SPLAT_TABLE_COLOR_G = 11, + SPLAT_TABLE_COLOR_B = 12, + SPLAT_TABLE_OPACITY = 13, + SPLAT_TABLE_SH_OFFSET = 14 +}; + +inline splat::Splat read_splat(const std::vector>& buffers, size_t sh_size, threading::ThreadPool& pool) { + auto thread_count = pool.thread_count(); + auto read_count = std::atomic(0); + auto gaussian_count = buffers[0].size(); + auto gaussians = std::vector(gaussian_count); + + auto gaussian_per_thread = std::max(gaussian_count / thread_count, static_cast(1)); + auto futures = std::vector>(); + auto used_threads = std::min(gaussian_count, thread_count); + + futures.reserve(used_threads); + read_count.store(0, std::memory_order_release); + + auto process_gaussian = [&, buffers](size_t start, size_t count) { + for (auto i = 0; i < count; i++) { + auto index = start + i; + + auto& gaussian = gaussians[index]; + gaussian.mean = Eigen::Vector3f( + buffers[SPLAT_TABLE_MEAN_X][index], + buffers[SPLAT_TABLE_MEAN_Y][index], + buffers[SPLAT_TABLE_MEAN_Z][index]); + gaussian.scale = Eigen::Vector3f( + buffers[SPLAT_TABLE_SCALE_X][index], + buffers[SPLAT_TABLE_SCALE_Y][index], + buffers[SPLAT_TABLE_SCALE_Z][index]); + gaussian.rotation = Eigen::Vector4f( + buffers[SPLAT_TABLE_QUAT_X][index], + buffers[SPLAT_TABLE_QUAT_Y][index], + buffers[SPLAT_TABLE_QUAT_Z][index], + buffers[SPLAT_TABLE_QUAT_W][index]); + gaussian.opacity = buffers[SPLAT_TABLE_OPACITY][index]; + + // SH + gaussian.sh = ::splat::SH(sh_size + 3); + gaussian.sh[0] = buffers[SPLAT_TABLE_COLOR_R][index]; + gaussian.sh[1] = buffers[SPLAT_TABLE_COLOR_G][index]; + gaussian.sh[2] = buffers[SPLAT_TABLE_COLOR_B][index]; + for (auto j = 3; j < gaussian.sh.size(); j++) { + gaussian.sh[j] = buffers[SPLAT_TABLE_SH_OFFSET + j - 3][index]; + } + + gaussian.rotation.normalize(); + gaussian.compute_covariance(); + gaussian.compute_bounding_box(); + } + read_count.fetch_add(count, std::memory_order_acq_rel); + }; + + for (auto i = 0; i < used_threads; i++) { + futures.push_back(pool.submit_task(process_gaussian, gaussian_per_thread * i, gaussian_per_thread)); + } + + process_gaussian(gaussian_per_thread * used_threads, gaussian_count - gaussian_per_thread * used_threads); + + helpers::future::drain_futures(futures); + + assert(read_count.load(std::memory_order_acquire) == gaussian_count); + + auto splat = splat::Splat { + .gaussians = std::move(gaussians), + .bounding_box = Eigen::AlignedBox3f {}, + }; + splat.compute_bounding_box(); + return splat; +} + +inline std::vector write_splat( + Napi::Env& env, + size_t buffer_count, + const splat::Splat& splat, + threading::ThreadPool& pool) { + auto buffers = std::vector(); + auto buffer_spans = std::vector>(); + auto thread_count = pool.thread_count(); + auto written_count = std::atomic(0); + buffers.reserve(buffer_count); + buffer_spans.reserve(buffer_count); + + for (auto i = 0; i < buffer_count; i++) { + buffers.push_back(Napi::Float32Array::New(env, splat.gaussians.size())); + buffer_spans.emplace_back(buffers.back().Data(), splat.gaussians.size()); + } + + auto gaussian_per_thread = std::max(splat.gaussians.size() / thread_count, static_cast(1)); + auto futures = std::vector>(); + auto used_threads = std::min(splat.gaussians.size(), thread_count); + + futures.reserve(used_threads); + written_count.store(0, std::memory_order_release); + + // transform gaussian to js readable struct + auto process_gaussian = [&](size_t start, size_t count) { + for (auto i = 0; i < count; i++) { + auto index = start + i; + auto write_index = index; + auto& gaussian = splat.gaussians[index]; + + buffer_spans[SPLAT_TABLE_MEAN_X][write_index] = gaussian.mean.x(); + buffer_spans[SPLAT_TABLE_MEAN_Y][write_index] = gaussian.mean.y(); + buffer_spans[SPLAT_TABLE_MEAN_Z][write_index] = gaussian.mean.z(); + buffer_spans[SPLAT_TABLE_SCALE_X][write_index] = gaussian.scale.x(); + buffer_spans[SPLAT_TABLE_SCALE_Y][write_index] = gaussian.scale.y(); + buffer_spans[SPLAT_TABLE_SCALE_Z][write_index] = gaussian.scale.z(); + buffer_spans[SPLAT_TABLE_QUAT_X][write_index] = gaussian.rotation.x(); + buffer_spans[SPLAT_TABLE_QUAT_Y][write_index] = gaussian.rotation.y(); + buffer_spans[SPLAT_TABLE_QUAT_Z][write_index] = gaussian.rotation.z(); + buffer_spans[SPLAT_TABLE_QUAT_W][write_index] = gaussian.rotation.w(); + buffer_spans[SPLAT_TABLE_COLOR_R][write_index] = gaussian.sh[0]; + buffer_spans[SPLAT_TABLE_COLOR_G][write_index] = gaussian.sh[1]; + buffer_spans[SPLAT_TABLE_COLOR_B][write_index] = gaussian.sh[2]; + buffer_spans[SPLAT_TABLE_OPACITY][write_index] = gaussian.opacity; + + for (auto j = 3; j < gaussian.sh.size(); j++) { + buffer_spans[SPLAT_TABLE_SH_OFFSET + j - 3][write_index] = gaussian.sh[j]; + } + } + + written_count.fetch_add(count, std::memory_order_acq_rel); + }; + + for (auto i = 0; i < used_threads; i++) { + futures.push_back(pool.submit_task(process_gaussian, gaussian_per_thread * i, gaussian_per_thread)); + } + + process_gaussian(gaussian_per_thread * used_threads, splat.gaussians.size() - gaussian_per_thread * used_threads); + + helpers::future::drain_futures(futures); + + assert(written_count.load(std::memory_order_acquire) == splat.gaussians.size()); + + return buffers; +} +} // namespace +namespace node_api::splat { +Napi::Value generate_lod(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 7 || !info[0].IsArray() || !info[1].IsNumber() || !info[2].IsBuffer() || !info[3].IsNumber() || !info[4].IsNumber() || !info[5].IsNumber() || !info[6].IsObject()) { + Napi::TypeError::New(env, "Wrong Arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + + auto& pool = node_api::threading::ThreadPool::Unwrap(info[6].As())->impl(); + size_t thread_count = pool.thread_count(); + + auto sh_size = info[1].As().Uint32Value(); + std::vector<::splat::Splat> blocks; + // read & create blocks. + { + auto buffers = std::vector>(); + auto array = info[0].As(); + buffers.reserve(sh_size + SPLAT_TABLE_SH_OFFSET); + for (auto i = 0; i < sh_size + SPLAT_TABLE_SH_OFFSET; i++) { + auto buffer = array[i].AsValue().As>(); + buffers.emplace_back(buffer.Data(), buffer.Length()); + } + blocks = ::splat::block::split( + read_splat(buffers, sh_size, pool), + info[3].As().DoubleValue()); + } + + auto level_parameters = info[2].As>(); + auto levels = level_parameters.Length(); + auto level_parameters_span = std::span(level_parameters.Data() + 1, level_parameters.Length() - 1); + + auto results = std::vector<::splat::Splat>(); + auto gaussian_count = std::make_unique>(); + auto block_boxes = std::make_unique>(); + auto block_refs = std::make_unique>(); + + // reverse data, small. + results.reserve(blocks.size() * levels); + gaussian_count->reserve(blocks.size() * levels); + block_boxes->reserve(blocks.size() * 6); + block_refs->reserve(blocks.size() * levels); + + { + auto futures = std::vector>(); + auto used_threads = std::min(blocks.size(), thread_count); + auto min_size = info[4].As().Uint32Value(); + auto max_step = info[5].As().Uint32Value(); + + futures.reserve(blocks.size()); + + auto process_block = [&, levels, max_step](size_t index) -> ::splat::lod::SplatLod { + auto& block = blocks[index]; + auto lod = ::splat::lod::SplatLod(); + lod.levels.reserve(levels); + lod.splats.reserve(levels); + lod.levels.push_back(0); + lod.splats.push_back(std::move(block)); + if (level_parameters_span.size() > 0) { + ::splat::lod::generate_lod(index, lod, level_parameters_span, min_size, max_step); + } + return lod; + }; + + for (auto i = 0; i < blocks.size(); i++) { + futures.push_back(pool.submit_task(process_block, i)); + } + + helpers::future::drain_futures(futures, [&](::splat::lod::SplatLod block) { + auto base_offset = static_cast(results.size()); + { + auto& bbx = block.splats.front().bounding_box; + helpers::container::append_range(*block_boxes, bbx.min()); + helpers::container::append_range(*block_boxes, bbx.max()); + } + helpers::container::append_range(*gaussian_count, block.splats | std::views::transform([](::splat::Splat& splat) -> uint32_t { + return static_cast(splat.gaussians.size()); + })); + helpers::container::append_range(*block_refs, block.levels | std::views::transform([base_offset](uint32_t ref) -> uint32_t { + return ref + base_offset; + })); + for (auto& splat : block.splats) { + results.push_back(std::move(splat)); + } + }); + + // release blocks + { + auto _ = std::move(blocks); + } + } + + // auto total = std::reduce(gaussian_count->begin(), gaussian_count->end()); + auto buffers_per_splat = sh_size + SPLAT_TABLE_SH_OFFSET; + auto buffers = std::vector(); + + buffers.reserve(buffers_per_splat * results.size()); + + for (auto& splat : results) { + helpers::container::append_range(buffers, write_splat(env, buffers_per_splat, splat, pool)); + + // free data already transformed. + { + auto _ = std::move(splat); + } + } + + auto object = Napi::Object::New(env); + { + auto data = Napi::Array::New(env, buffers.size()); + auto offset = 0; + object.Set("data", data); + for (auto i = 0; i < buffers.size(); i++) { + data[i] = buffers[i]; + } + auto _ = std::move(buffers); + } + object.Set("blockBoxes", node_api::buffer::UniqueVecBufferFinalizer::make_buffer(env, std::move(block_boxes))); + object.Set("blockRefs", node_api::buffer::UniqueVecBufferFinalizer::make_buffer(env, std::move(block_refs))); + object.Set("gaussianCount", node_api::buffer::UniqueVecBufferFinalizer::make_buffer(env, std::move(gaussian_count))); + return object; +} + +Napi::Value split(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 4 || !info[0].IsArray() || !info[1].IsNumber() || !info[2].IsNumber() || !info[3].IsObject()) { + Napi::TypeError::New(env, "Wrong Arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + + auto& pool = node_api::threading::ThreadPool::Unwrap(info[3].As())->impl(); + size_t thread_count = pool.thread_count(); + + auto sh_size = info[1].As().Uint32Value(); + std::vector<::splat::Splat> blocks; + // read & create blocks. + { + auto buffers = std::vector>(); + auto array = info[0].As(); + buffers.reserve(sh_size + SPLAT_TABLE_SH_OFFSET); + for (auto i = 0; i < sh_size + SPLAT_TABLE_SH_OFFSET; i++) { + auto buffer = array[i].AsValue().As>(); + buffers.emplace_back(buffer.Data(), buffer.Length()); + } + blocks = ::splat::block::split( + read_splat(buffers, sh_size, pool), + info[2].As().DoubleValue()); + } + + auto buffers_per_splat = sh_size + SPLAT_TABLE_SH_OFFSET; + auto block_boxes = std::make_unique>(); + auto gaussian_count = std::make_unique>(); + auto buffers = std::vector(); + + block_boxes->reserve(blocks.size() * 6); + gaussian_count->reserve(blocks.size()); + buffers.reserve(buffers_per_splat * blocks.size()); + + for (auto& splat : blocks) { + + helpers::container::append_range(buffers, write_splat(env, buffers_per_splat, splat, pool)); + gaussian_count->push_back(static_cast(splat.gaussians.size())); + { + auto& bbx = splat.bounding_box; + helpers::container::append_range(*block_boxes, bbx.min()); + helpers::container::append_range(*block_boxes, bbx.max()); + } + + // free data already transformed. + { + auto _ = std::move(splat); + } + } + + auto object = Napi::Object::New(env); + { + auto data = Napi::Array::New(env, buffers.size()); + auto offset = 0; + object.Set("data", data); + for (auto i = 0; i < buffers.size(); i++) { + data[i] = buffers[i]; + } + auto _ = std::move(buffers); + } + object.Set("blockBoxes", node_api::buffer::UniqueVecBufferFinalizer::make_buffer(env, std::move(block_boxes))); + object.Set("gaussianCount", node_api::buffer::UniqueVecBufferFinalizer::make_buffer(env, std::move(gaussian_count))); + return object; +} +} // namespace node_api::splat diff --git a/packages/splat-transform-native/source/src/node/api_thread_pool.cpp b/packages/splat-transform-native/source/src/node/api_thread_pool.cpp new file mode 100644 index 0000000..d599805 --- /dev/null +++ b/packages/splat-transform-native/source/src/node/api_thread_pool.cpp @@ -0,0 +1,42 @@ +#include +#include +#include + +namespace node_api::threading { +Napi::FunctionReference ThreadPool::Init(Napi::Env env, Napi::Object exports) { + auto ctor = DefineClass(env, "ThreadPool", { + InstanceAccessor("threadCount", &ThreadPool::thread_count, nullptr, static_cast(napi_enumerable | napi_configurable)), + InstanceAccessor("taskCount", &ThreadPool::task_count, nullptr, static_cast(napi_enumerable | napi_configurable)), + }); + exports.Set("ThreadPool", ctor); + return Napi::Persistent(ctor); +} + +ThreadPool::ThreadPool(const Napi::CallbackInfo& info) + : Napi::ObjectWrap(info), impl_(calc_thread_count(info)) { +} + +Napi::Value ThreadPool::thread_count(const Napi::CallbackInfo& info) { + return Napi::Number::New(info.Env(), static_cast(this->impl_.thread_count())); +} + +Napi::Value ThreadPool::task_count(const Napi::CallbackInfo& info) { + return Napi::Number::New(info.Env(), static_cast(this->impl_.task_count())); +} + +::threading::ThreadPool& ThreadPool::impl() noexcept { + return this->impl_; +} + +size_t ThreadPool::calc_thread_count(const Napi::CallbackInfo& info) { + size_t thread_count = 0u; + if (info.Length() > 0 && info[0].IsNumber()) { + thread_count = info[0].As().Uint32Value(); + } + if (thread_count == 0) { + thread_count = std::min(std::max(std::bit_ceil(std::thread::hardware_concurrency()) / 2u, 1u), 16u); + } + + return thread_count; +} +} // namespace node_api::threading diff --git a/packages/splat-transform-native/source/src/node/api_webp.cpp b/packages/splat-transform-native/source/src/node/api_webp.cpp new file mode 100644 index 0000000..216ee3c --- /dev/null +++ b/packages/splat-transform-native/source/src/node/api_webp.cpp @@ -0,0 +1,108 @@ +#include +#include +#include +#include +#include + +namespace { +size_t webp_encode_rgba(const std::span& data, int32_t width, int32_t height, bool lossless, float quality, uint8_t** output) { + WebPPicture pic; + WebPConfig config; + WebPMemoryWriter wrt; + int ok; + + if (output == nullptr) { + return 0; + } + + if (!WebPConfigPreset(&config, WEBP_PRESET_DEFAULT, quality) || + !WebPPictureInit(&pic)) { + return 0; // shouldn't happen, except if system installation is broken + } + + config.lossless = static_cast(lossless); + config.exact = 1; + pic.use_argb = static_cast(lossless); + pic.width = width; + pic.height = height; + pic.writer = WebPMemoryWrite; + pic.custom_ptr = &wrt; + WebPMemoryWriterInit(&wrt); + + ok = WebPPictureImportRGBA(&pic, data.data(), 4 * width) && WebPEncode(&config, &pic); + WebPPictureFree(&pic); + if (!ok) { + WebPMemoryWriterClear(&wrt); + return 0; + } + *output = wrt.mem; + return wrt.size; +} +} // namespace + +namespace node_api::imaging { +Napi::Value webp_encode_rgba(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 4 || !info[0].IsBuffer() || !info[1].IsNumber() || !info[2].IsNumber() || !info[3].IsNumber()) { + Napi::TypeError::New(env, "Wrong Arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + auto buffer = info[0].As>(); + auto width = info[1].As().Int32Value(); + auto height = info[2].As().Int32Value(); + auto quality = info[3].As().FloatValue(); + auto span = std::span(buffer.Data(), buffer.Length()); + + uint8_t* output = nullptr; + auto compressed = ::webp_encode_rgba(span, width, height, false, quality, &output); + if (compressed == 0) { + Napi::TypeError::New(env, "WebP Encode Failed").ThrowAsJavaScriptException(); + return env.Null(); + } + return node_api::buffer::UniqueBufferFinalizer::make_buffer(env, std::unique_ptr(output, WebPFree), compressed); +} + +Napi::Value webp_encode_rgba_lossless(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 3 || !info[0].IsBuffer() || !info[1].IsNumber() || !info[2].IsNumber()) { + Napi::TypeError::New(env, "Wrong Arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + + auto buffer = info[0].As>(); + auto width = info[1].As().Int32Value(); + auto height = info[2].As().Int32Value(); + auto span = std::span(buffer.Data(), buffer.Length()); + + uint8_t* output = nullptr; + auto compressed = ::webp_encode_rgba(span, width, height, true, 70, &output); + if (compressed == 0) { + Napi::TypeError::New(env, "WebP Encode Failed").ThrowAsJavaScriptException(); + return env.Null(); + } + return node_api::buffer::UniqueBufferFinalizer::make_buffer(env, std::unique_ptr(output, WebPFree), compressed); +} + +Napi::Value webp_decode_rgba(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 1 || !info[0].IsBuffer()) { + Napi::TypeError::New(env, "Wrong Arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + auto buffer = info[0].As>(); + int32_t width = 0; + int32_t height = 0; + auto rgba = WebPDecodeRGBA(buffer.Data(), buffer.Length(), &width, &height); + if (rgba == nullptr) { + Napi::TypeError::New(env, "WebP Decode Failed").ThrowAsJavaScriptException(); + return env.Null(); + } + auto object = Napi::Object::New(env); + { + object.Set("data", node_api::buffer::UniqueBufferFinalizer::make_buffer(env, std::unique_ptr(rgba, WebPFree), width * height * 4)); + object.Set("width", Napi::Number::New(env, width)); + object.Set("height", Napi::Number::New(env, height)); + } + return object; +} +} // namespace node_api::imaging diff --git a/packages/splat-transform-native/source/src/node/binding.cpp b/packages/splat-transform-native/source/src/node/binding.cpp new file mode 100644 index 0000000..0f83b55 --- /dev/null +++ b/packages/splat-transform-native/source/src/node/binding.cpp @@ -0,0 +1,57 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +struct InstanceData { + Napi::FunctionReference thread_pool; +}; + +template + requires requires(F f, const Napi::CallbackInfo& info) { + { f(info) } -> std::same_as; + } +auto wrap_call(F&& f) { + return [f = std::forward(f)](const Napi::CallbackInfo& info) { + try { + return f(info); + } catch (const Napi::Error&) { + throw; + } catch (const std::exception& ex) { + auto env = info.Env(); + Napi::Error::New(env, ex.what()).ThrowAsJavaScriptException(); + return env.Null(); + } catch (...) { + auto env = info.Env(); + Napi::Error::New(env, "Unknown runtime exception").ThrowAsJavaScriptException(); + return env.Null(); + } + }; +} +} // namespace + +static Napi::Object Init(Napi::Env env, Napi::Object exports) { + exports.Set("generate_splat_lod", Napi::Function::New(env, wrap_call(node_api::splat::generate_lod))); + exports.Set("split_splat", Napi::Function::New(env, wrap_call(node_api::splat::split))); + exports.Set("webp_encode_rgba", Napi::Function::New(env, wrap_call(node_api::imaging::webp_encode_rgba))); + exports.Set("webp_encode_rgba_lossless", Napi::Function::New(env, wrap_call(node_api::imaging::webp_encode_rgba_lossless))); + exports.Set("webp_decode_rgba", Napi::Function::New(env, wrap_call(node_api::imaging::webp_decode_rgba))); + exports.Set("avif_encode_rgba", Napi::Function::New(env, wrap_call(node_api::imaging::avif_encode_rgba))); + exports.Set("avif_encode_rgba_batched", Napi::Function::New(env, wrap_call(node_api::imaging::avif_encode_rgba_batched))); + exports.Set("avif_decode_rgba", Napi::Function::New(env, wrap_call(node_api::imaging::avif_decode_rgba))); + exports.Set("avif_decode_rgba_batched", Napi::Function::New(env, wrap_call(node_api::imaging::avif_decode_rgba_batched))); + exports.Set("cluster_average", Napi::Function::New(env, wrap_call(node_api::spatial::cluster_average))); + auto instance_data = new InstanceData { + .thread_pool = node_api::threading::ThreadPool::Init(env, exports) + }; + env.SetInstanceData(instance_data); + return exports; +} + +NODE_API_MODULE(addon_napi, Init) diff --git a/packages/splat-transform-native/source/src/splat/splat.cpp b/packages/splat-transform-native/source/src/splat/splat.cpp new file mode 100644 index 0000000..8039c23 --- /dev/null +++ b/packages/splat-transform-native/source/src/splat/splat.cpp @@ -0,0 +1,188 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace splat { +SH::SH() noexcept : size_(0), ptr(nullptr) { +} + +SH::SH(size_t size) : size_(size), ptr(size > 0 ? std::make_unique(size) : nullptr) { +} + +SH::SH(const SH& other) : size_(other.size_), ptr(other.size_ > 0 ? std::make_unique(other.size_) : nullptr) { + if (this->ptr != nullptr) { + std::copy(other.ptr.get(), other.ptr.get() + this->size_, this->ptr.get()); + } +} + +SH::SH(SH&& other) noexcept : size_(std::exchange(other.size_, 0)), + ptr(std::exchange(other.ptr, nullptr)) { +} + +SH& SH::operator=(const SH& other) { + if (this != &other) { + this->reset(); + if (other.size_ > 0) { + this->ptr = std::make_unique(other.size_); + std::copy(other.ptr.get(), other.ptr.get() + other.size_, this->ptr.get()); + } + this->size_ = other.size_; + } + return *this; +} + +SH& SH::operator=(SH&& other) noexcept { + if (this != &other) { + this->size_ = std::exchange(other.size_, 0); + this->ptr = std::exchange(other.ptr, nullptr); + } + return *this; +} + +void SH::swap(SH& other) noexcept { + std::swap(this->ptr, other.ptr); + std::swap(this->size_, other.size_); +} + +SH& SH::set_zero() noexcept { + if (this->ptr) { + std::memset(this->ptr.get(), 0, sizeof(float) * this->size_); + } + + return *this; +} + +SH& SH::add_multiplied(const SH& other, float scalar) noexcept { + assert(other.size_ == this->size_); + + for (auto i = 0; i < this->size_; i++) { + this->ptr[i] += other.ptr[i] * scalar; + } + + return *this; +} + +const float& SH::operator[](size_t index) const { + return this->ptr[index]; +} + +float& SH::operator[](size_t index) { + return this->ptr[index]; +} + +void SH::reset() noexcept { + this->ptr.reset(); + this->size_ = 0; +} + +float* SH::data() noexcept { + return this->ptr.get(); +} + +size_t SH::size() const noexcept { + return this->size_; +} + +SH::~SH() noexcept { +} + +void Gaussian::compute_bounding_box(float k) { + auto sigma = this->scale.cwiseAbs(); + this->bounding_box = Eigen::AlignedBox3f(this->mean - k * sigma, this->mean + k * sigma); +} + +void Gaussian::compute_covariance() { + Eigen::Matrix3f s = Eigen::Matrix3f::Identity(); + + s(0, 0) = this->scale.x(); + s(1, 1) = this->scale.y(); + s(2, 2) = this->scale.z(); + + auto r = Eigen::Matrix3f(Eigen::Quaternionf(this->rotation)); + + Eigen::Matrix3f t = r * s; + + this->covariance = t * t.transpose(); +} + +void Gaussian::decompose_covariance() { + auto matrix = this->covariance; + Eigen::SelfAdjointEigenSolver eigen_solver(matrix); + auto eigen_values = eigen_solver.eigenvalues(); + auto eigen_vectors = eigen_solver.eigenvectors(); + + if (eigen_values.hasNaN()) { + throw std::runtime_error("Found Nans in covariance decomposing!"); + } + + int loops = 0; + while (eigen_values[0] == 0 || eigen_values[1] == 0 || eigen_values[2] == 0) { + matrix(0, 0) += std::max(matrix(0, 0) * 0.0001f, std::numeric_limits::epsilon()); + matrix(1, 1) += std::max(matrix(1, 1) * 0.0001f, std::numeric_limits::epsilon()); + matrix(2, 2) += std::max(matrix(2, 2) * 0.0001f, std::numeric_limits::epsilon()); + + Eigen::SelfAdjointEigenSolver eigen_solver2(matrix); + eigen_values = eigen_solver2.eigenvalues(); + eigen_vectors = eigen_solver2.eigenvectors(); + + loops++; + } + + auto v1 = eigen_vectors.col(0); + auto v2 = eigen_vectors.col(1); + auto v3 = eigen_vectors.col(2); + + auto test = v1.cross(v2); + + if (test.dot(v3) < 0) { + eigen_vectors.col(2) *= -1; + } + + float a = std::sqrt(std::abs(eigen_values.x())); + float b = std::sqrt(std::abs(eigen_values.y())); + float c = std::sqrt(std::abs(eigen_values.z())); + + this->scale = { a, b, c }; + auto q = Eigen::Quaternionf(eigen_vectors); + this->rotation = { q.x(), q.y(), q.z(), q.w() }; + this->rotation.normalize(); +} + +float Gaussian::area() const { + constexpr float P = 1.6075f; + auto numerator = std::pow(this->scale.x() * this->scale.y(), P) + std::pow(this->scale.x() * this->scale.z(), P) + + std::pow(this->scale.y() * this->scale.z(), P); + return 4.0f * std::numbers::pi_v * std::pow(numerator / 3.0f, 1.0f / P); +} + +void Splat::compute_bounding_box() { + this->bounding_box = Eigen::AlignedBox3f(); + for (auto& gaussian : this->gaussians) { + this->bounding_box.extend(gaussian.bounding_box); + } +} + +void Splat::compute_compact_bounding_box() { + this->bounding_box = Eigen::AlignedBox3f(); + auto ref = std::vector>(); + ref.reserve(this->gaussians.size()); + for (auto i = 0; i < this->gaussians.size(); i++) { + ref.emplace_back(i, this->gaussians[i].area()); + } + std::sort(ref.begin(), ref.end(), [](auto& a, auto& b) -> bool { return std::get<1>(a) < std::get<1>(b); }); + auto needed = static_cast(static_cast(this->gaussians.size()) * 0.95); + if (needed == 0) { + needed = this->gaussians.size(); + } + for (auto i = 0; i < needed; i++) { + this->bounding_box.extend(this->gaussians[std::get<0>(ref[i])].bounding_box); + } +} +} // namespace splat diff --git a/packages/splat-transform-native/source/src/splat/splat_block.cpp b/packages/splat-transform-native/source/src/splat/splat_block.cpp new file mode 100644 index 0000000..8df79cb --- /dev/null +++ b/packages/splat-transform-native/source/src/splat/splat_block.cpp @@ -0,0 +1,137 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +struct RefSplat { + const splat::Splat& source; + std::vector gaussians; + Eigen::AlignedBox3f box; + bool need_split; + + RefSplat(const splat::Splat& splat) : source(splat), box(splat.bounding_box), need_split(true) { + this->gaussians.reserve(splat.gaussians.size()); + for (auto i = 0; i < splat.gaussians.size(); i++) { + this->gaussians.push_back(i); + } + } + RefSplat(RefSplat& splat) : source(splat.source), need_split(true) {} + RefSplat(RefSplat&& splat) = default; + + void compute_bounding_box() { + this->box = Eigen::AlignedBox3f(); + for (auto index : this->gaussians) { + this->box.extend(this->source.gaussians[index].bounding_box); + } + } + + void compute_need_split(const Eigen::AlignedBox3f& parent_box, size_t max_block_size) { + this->need_split = this->gaussians.size() > max_block_size && + ((this->box.min() - parent_box.min()).norm() > 0.001 || + (this->box.max() - parent_box.max()).norm() > 0.001); + } + + splat::Splat to_owned() { + std::vector gaussians; + std::vector sh; + gaussians.reserve(this->gaussians.size()); + for (auto index : this->gaussians) { + gaussians.push_back(this->source.gaussians[index]); + } + { + auto _ = std::move(this->gaussians); + } + auto result = splat::Splat { + .gaussians = std::move(gaussians), + .bounding_box = this->box, + }; + result.compute_compact_bounding_box(); + return result; + } +}; + +std::array split_box(const Eigen::AlignedBox3f& box) { + auto center = box.center(); + return { + Eigen::AlignedBox3f().extend(center).extend(box.corner(Eigen::AlignedBox3f::CornerType::BottomLeftFloor)), + Eigen::AlignedBox3f().extend(center).extend(box.corner(Eigen::AlignedBox3f::CornerType::BottomRightFloor)), + Eigen::AlignedBox3f().extend(center).extend(box.corner(Eigen::AlignedBox3f::CornerType::TopLeftFloor)), + Eigen::AlignedBox3f().extend(center).extend(box.corner(Eigen::AlignedBox3f::CornerType::TopRightFloor)), + Eigen::AlignedBox3f().extend(center).extend(box.corner(Eigen::AlignedBox3f::CornerType::BottomLeftCeil)), + Eigen::AlignedBox3f().extend(center).extend(box.corner(Eigen::AlignedBox3f::CornerType::BottomRightCeil)), + Eigen::AlignedBox3f().extend(center).extend(box.corner(Eigen::AlignedBox3f::CornerType::TopLeftCeil)), + Eigen::AlignedBox3f().extend(center).extend(box.corner(Eigen::AlignedBox3f::CornerType::TopRightCeil)), + }; +} + +std::array split_block(RefSplat& splat, size_t max_block_size) { + auto boxes = split_box(splat.box); + std::array result { RefSplat(splat), + RefSplat(splat), + RefSplat(splat), + RefSplat(splat), + RefSplat(splat), + RefSplat(splat), + RefSplat(splat), + RefSplat(splat) }; + + for (auto& r : result) { + r.gaussians.reserve(splat.gaussians.size() / 4); + } + + for (auto index : splat.gaussians) { + auto& box = splat.source.gaussians[index].bounding_box; + auto max_interaction = 0.0; + auto max_interaction_index = 0; + for (auto i = 0; i < 8; i++) { + auto intersection = boxes[i].intersection(box); + if (!intersection.isEmpty()) { + auto current = intersection.volume(); + if (current > max_interaction) { + max_interaction = current; + max_interaction_index = i; + } + } + } + result[max_interaction_index].gaussians.push_back(index); + } + + for (auto i = 0; i < result.size(); i++) { + result[i].box = boxes[i]; + result[i].compute_need_split(splat.box, max_block_size); + } + + return result; +} +} // namespace + +namespace splat::block::detail { +std::vector split(const Splat& splat, size_t max_block_size) { + if (splat.gaussians.size() <= max_block_size) { + return std::vector({ splat }); + } + + std::queue queue; + std::vector results; + queue.emplace(splat); + + while (!queue.empty()) { + auto r = std::move(queue.front()); + queue.pop(); + if (r.need_split) { + for (auto& splitted : split_block(r, max_block_size)) { + queue.emplace(std::move(splitted)); + } + } else if (r.gaussians.size() > 0) { + results.emplace_back(r.to_owned()); + } + } + + return results; +} +} // namespace splat::block::detail diff --git a/packages/splat-transform-native/source/src/splat/splat_lod.cpp b/packages/splat-transform-native/source/src/splat/splat_lod.cpp new file mode 100644 index 0000000..9092613 --- /dev/null +++ b/packages/splat-transform-native/source/src/splat/splat_lod.cpp @@ -0,0 +1,396 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr double TWO_PI_POW_1_5 = 0x1.f7fccep+3; +constexpr double EPS_COV = 1e-8; +constexpr double LOG_2PI = 1.8378771; +constexpr float OPACITY_PRUNE_THRESHOLD = 0.005f; + +class GaussianCloud { +public: + GaussianCloud(const splat::Splat& splat) : splat(splat) {} + + inline size_t kdtree_get_point_count() const { return splat.gaussians.size(); } + inline float kdtree_get_pt(size_t i, int d) const { return splat.gaussians[i].mean[d]; } + template + bool kdtree_get_bbox(BBOX&) const { return false; } + +private: + const splat::Splat& splat; +}; + +struct GaussianCache { + Eigen::Matrix3d rotation; + Eigen::Matrix3d rotation_transpose; + Eigen::Matrix3d sigma; + Eigen::Vector3d variances; + Eigen::Vector3d inv_diag; + double log_det; + double mass; +}; + +using KDTree = nanoflann::KDTreeSingleIndexAdaptor< + nanoflann::L2_Simple_Adaptor, + GaussianCloud, 3>; + +// https://github.com/graphdeco-inria/gaussian-hierarchy/ +static splat::Gaussian merge_gaussians(const splat::Splat& input, const std::vector& idx, float scale_boost) { + splat::Gaussian out; + out.mean.setZero(); + out.covariance.setZero(); + out.scale.setZero(); + out.rotation.setZero(); + out.sh = splat::SH(input.gaussians[0].sh.size()); + out.sh.set_zero(); + out.opacity = 0.0; + + std::vector weights(idx.size()); + float total_weight = 0.0f; + + for (auto i = 0; i < idx.size(); i++) { + const auto& g = input.gaussians[idx[i]]; + weights[i] = g.area() * g.opacity; + total_weight += weights[i]; + } + + for (auto& weight : weights) { + weight /= total_weight; + } + + for (auto i = 0; i < idx.size(); i++) { + const auto& g = input.gaussians[idx[i]]; + out.mean += g.mean * weights[i]; + out.sh.add_multiplied(g.sh, weights[i]); + } + + for (auto i = 0; i < idx.size(); i++) { + const auto& g = input.gaussians[idx[i]]; + auto d = g.mean - out.mean; + out.covariance += weights[i] * (g.covariance + d * d.transpose()); + } + + out.decompose_covariance(); + out.scale *= scale_boost; + out.opacity = total_weight / out.area(); + + if (out.opacity > 1.0f) { + out.scale *= std::pow(out.opacity, 1.0f / 3.0f); + out.opacity = 1.0; + } + out.compute_covariance(); + + return out; +} + +static Eigen::Matrix3d sigma_from_rotation_variances(const Eigen::Matrix3d& rotation, const Eigen::Vector3d& variances) { + Eigen::Matrix3d s = Eigen::Matrix3d::Identity(); + + s(0, 0) = variances.x(); + s(1, 1) = variances.y(); + s(2, 2) = variances.z(); + + return rotation * s * rotation.transpose(); +} + +static void build_cache(const splat::Splat& splat, std::vector& caches) { + static Eigen::Vector3d VECTOR_ONE(1.0, 1.0, 1.0); + static Eigen::Vector3d VECTOR_EPS(EPS_COV, EPS_COV, EPS_COV); + + for (auto& gaussian : splat.gaussians) { + auto rotation = Eigen::Matrix3d(Eigen::Quaterniond(gaussian.rotation.w(), gaussian.rotation.x(), gaussian.rotation.y(), gaussian.rotation.z())); + auto t = rotation.transpose(); + Eigen::Vector3f vf = gaussian.scale.cwiseMax(1e-12f).cwiseSquare(); + Eigen::Vector3d v = Eigen::Vector3d(vf.x(), vf.y(), vf.z()) + VECTOR_EPS; + Eigen::Vector3d safe_v = v.cwiseMax(1e-30); + + caches.push_back(GaussianCache { + .rotation = rotation, + .rotation_transpose = t, + .sigma = sigma_from_rotation_variances(rotation, v), + .variances = v, + .inv_diag = VECTOR_ONE.array() / safe_v.array(), + .log_det = safe_v.array().log().sum(), + .mass = TWO_PI_POW_1_5 * gaussian.opacity * gaussian.scale.prod() + 1e-12 }); + } +} + +std::vector make_gaussian_samples(size_t n, uint32_t seed) { + std::vector result; + result.reserve(n); + auto rand = [&seed]() -> double { + auto t = (seed += 0x6d2b79f5); + t = (t ^ (t >> 15)) * (t | 1); + t ^= t + (t ^ (t >> 7)) * (t | 61); + return static_cast((t ^ (t >> 14)) >> 0) / 4294967296.0; + }; + + for (auto i = 0; i < n; i++) { + auto u1 = std::max(rand(), 1e-12); + auto u2 = rand(); + auto u3 = std::max(rand(), 1e-12); + auto u4 = rand(); + auto r1 = std::sqrt(-2 * std::log(u1)); + auto t1 = 2 * std::numbers::pi_v * u2; + auto r2 = std::sqrt(-2 * std::log(u3)); + auto t2 = 2 * std::numbers::pi_v * u4; + result.emplace_back(r1 * std::cos(t1), r1 * std::sin(t1), r2 * std::cos(t2)); + } + + return result; +} + +static double gauss_log_pdf_diagrot(const Eigen::Vector3d& v, const Eigen::Vector3d& m, const Eigen::Matrix3d& r, const Eigen::Vector3d& inv, double log_det) { + Eigen::Vector3d d = v - m; + Eigen::Vector3d y = r * d; + double quad = y.cwiseSquare().transpose() * inv; + return -0.5 * (3.0 * LOG_2PI + log_det + quad); +} + +static double log_add_exp(double a, double b) { + if (-std::numeric_limits::infinity() == a) { + return b; + } + if (-std::numeric_limits::infinity() == b) { + return a; + } + auto m = std::max(a, b); + return m + std::log(std::exp(a - m) + std::exp(b - m)); +}; + +static double compute_edge_cost(const splat::Splat& splat, const std::vector& caches, const std::vector& samples, std::tuple& edge) { + auto [i, j] = edge; + auto& u = splat.gaussians[i]; + auto& v = splat.gaussians[j]; + auto& cache_i = caches[i]; + auto& cache_j = caches[j]; + auto wi = cache_i.mass; + auto wj = cache_j.mass; + auto w = wi + wj; + auto w_safe = w > 0 ? w : 1; + auto pi = wi / w_safe; + pi = std::max(1e-12, std::min(1.0 - 1e-12, pi)); + auto pj = 1 - pi; + auto log_pi = std::log(pi); + auto log_pj = std::log(pj); + Eigen::Vector3d u_mean(u.mean.x(), u.mean.y(), u.mean.z()); + Eigen::Vector3d v_mean(v.mean.x(), v.mean.y(), v.mean.z()); + + auto mm = pi * u_mean + pj * v_mean; + auto di = u_mean - mm; + auto dj = v_mean - mm; + Eigen::Matrix3d sigma = pi * cache_i.sigma + pj * cache_j.sigma; + + sigma += pi * (di * di.transpose()) + pj * (dj * dj.transpose()); + + // Force symmetry + regularize + sigma(0, 1) = sigma(1, 0) = 0.5 * (sigma(0, 1) + sigma(1, 0)); + sigma(0, 2) = sigma(2, 0) = 0.5 * (sigma(0, 2) + sigma(2, 0)); + sigma(1, 2) = sigma(2, 1) = 0.5 * (sigma(1, 2) + sigma(2, 1)); + + sigma(0, 0) += EPS_COV; + sigma(1, 1) += EPS_COV; + sigma(2, 2) += EPS_COV; + + // det should never be neg. + auto det_m = std::max(sigma.determinant(), 1e-30); + auto log_det_m = std::log(det_m); + // E_p[-log q_m] computed analytically as entropy of merged Gaussian + auto ep_neg_log_q = 0.5 * (3.0 * LOG_2PI + log_det_m + 3.0); + + // Sample from each component separately with same z-vectors + Eigen::Vector3d std_i = cache_i.variances.cwiseMax(0).cwiseSqrt(); + Eigen::Vector3d std_j = cache_j.variances.cwiseMax(0).cwiseSqrt(); + + auto sum_log_p_on_i = 0.0; + auto sum_log_p_on_j = 0.0; + + for (auto& sample : samples) { + Eigen::Vector3d ti = std_i.array() * sample.array(); + Eigen::Vector3d tj = std_j.array() * sample.array(); + Eigen::Vector3d xi = u_mean + cache_i.rotation * ti; + Eigen::Vector3d xj = v_mean + cache_j.rotation * tj; + + // Evaluate log p_ij at samples from component i + auto log_ni_on_i = gauss_log_pdf_diagrot(xi, u_mean, cache_i.rotation_transpose, cache_i.inv_diag, cache_i.log_det); + auto log_nj_on_i = gauss_log_pdf_diagrot(xi, v_mean, cache_j.rotation_transpose, cache_j.inv_diag, cache_j.log_det); + sum_log_p_on_i += log_add_exp(log_pi + log_ni_on_i, log_pj + log_nj_on_i); + + // Evaluate log p_ij at samples from component j + auto log_ni_on_j = gauss_log_pdf_diagrot(xj, u_mean, cache_i.rotation_transpose, cache_i.inv_diag, cache_i.log_det); + auto log_nj_on_j = gauss_log_pdf_diagrot(xj, v_mean, cache_j.rotation_transpose, cache_j.inv_diag, cache_j.log_det); + sum_log_p_on_j += log_add_exp(log_pi + log_ni_on_j, log_pj + log_nj_on_j); + } + + auto ei = sum_log_p_on_i / samples.size(); + auto ej = sum_log_p_on_j / samples.size(); + auto ep_log_p = pi * ei + pj * ej; + auto geo = ep_log_p + ep_neg_log_q; + + auto c_sh = 0.0; + + for (auto i = 0; i < u.sh.size(); i++) { + auto d = u.sh[i] - v.sh[i]; + c_sh += (d * d); + } + + return geo + c_sh; +} + +inline static bool validate_gaussian(const splat::Gaussian& gaussian) { + return gaussian.scale.cwiseNotEqual(0.0f).all() && + gaussian.opacity >= OPACITY_PRUNE_THRESHOLD; +} +} // namespace + +namespace splat::lod::detail { +Splat reduce_gaussians(size_t id, const Splat& input, size_t target_count, float scale_boost, size_t max_step) { + assert(input.gaussians.size() > target_count); + + Splat current; + uint32_t knn_k = 16; + auto samples = make_gaussian_samples(1, 0); + std::vector out_indices; + std::vector out_distances; + std::vector order; + std::vector> edges; + std::vector> pairs; + std::vector used; + std::vector costs; + std::vector caches; + auto step = 0; + + // prune gaussians + current.gaussians.reserve(input.gaussians.size()); + helpers::container::append_range(current.gaussians, input.gaussians | std::views::filter(validate_gaussian)); + + while (current.gaussians.size() > target_count && current.gaussians.size() > 0 && step < max_step) { + GaussianCloud cloud(current); + KDTree tree(3, cloud, + nanoflann::KDTreeSingleIndexAdaptorParams( + 10, nanoflann::KDTreeSingleIndexAdaptorFlags::None, + std::max(std::bit_floor(std::thread::hardware_concurrency()) / 2u, 1u))); + + auto k_eff = std::min(std::max(1, knn_k), std::max(1, current.gaussians.size() - 1)); + size_t query_count = static_cast(std::min(current.gaussians.size(), k_eff + 1)); + auto max_pairs = current.gaussians.size() - target_count; + + assert(max_pairs > 0); + + Splat generated; + + edges.clear(); + edges.reserve(current.gaussians.size() * k_eff); + caches.clear(); + caches.reserve(current.gaussians.size()); + generated.gaussians.reserve(current.gaussians.size() / 2); + + build_cache(current, caches); + + // knn search + for (auto i = 0; i < current.gaussians.size(); i++) { + out_indices.assign(query_count, 0); + out_distances.assign(query_count, 0.0); + auto result_count = tree.knnSearch(current.gaussians[i].mean.data(), query_count, out_indices.data(), out_distances.data()); + const size_t take = std::min(k_eff, result_count > 0 ? result_count - 1 : size_t { 0 }); + for (auto j = 0; j < take; j++) { + auto neighbor = static_cast(out_indices[j + 1]); + if (neighbor <= i) { + continue; + } + edges.emplace_back(i, neighbor); + } + } + + assert(edges.size() > 0); + + // compute edge costs. + { + + costs.clear(); + costs.reserve(edges.size()); + for (auto& edge : edges) { + costs.push_back(compute_edge_cost(current, caches, samples, edge)); + } + } + + // take pairs + { + order.clear(); + order.reserve(edges.size()); + for (auto i = 0; i < costs.size(); ++i) { + if (std::isfinite(costs[i])) { + order.push_back(i); + } + } + + std::stable_sort(order.begin(), order.end(), [&](const size_t lhs, const size_t rhs) { + return costs[lhs] < costs[rhs]; + }); + + used.assign(current.gaussians.size(), false); + pairs.clear(); + pairs.reserve(max_pairs); + + for (auto edge_idx : order) { + const auto [u, v] = edges[edge_idx]; + if (used[u] || used[v]) { + continue; + } + used[u] = true; + used[v] = true; + pairs.emplace_back(u, v); + if (pairs.size() >= max_pairs) { + break; + } + } + } + + // merge pairs + for (auto& pair : pairs) { + auto [u, v] = pair; + auto g = merge_gaussians(current, { u, v }, scale_boost); + if (validate_gaussian(g)) { + generated.gaussians.push_back(std::move(g)); + } + } + + // keep unused gaussian + for (auto i = 0; i < current.gaussians.size(); i++) { + if (!used[i]) { + generated.gaussians.push_back(current.gaussians[i]); + } + } + + current = std::move(generated); + + // trace hard working on current block. + if (step >= max_step / 2) { + printf("block: %zu, step: %d, required: %zu, current: %zu(%.2f%%)\n", + id, step, target_count, current.gaussians.size(), + static_cast(current.gaussians.size()) / static_cast(target_count) * 100.0); + } + + // cannot reduce any more... + if (pairs.size() == 0) { + break; + } + + step++; + } + current.gaussians.shrink_to_fit(); + return current; +} +} // namespace splat::lod::detail diff --git a/packages/splat-transform-native/source/src/thread_pool.cpp b/packages/splat-transform-native/source/src/thread_pool.cpp new file mode 100644 index 0000000..efd2615 --- /dev/null +++ b/packages/splat-transform-native/source/src/thread_pool.cpp @@ -0,0 +1,134 @@ +#include +#include +#include +#include +#include +#include +#include + +namespace threading { +ThreadPool::ThreadPool(size_t thread_count) + : state(std::make_unique()) { + if (thread_count == 0) { + thread_count = 1; + } + + this->state->workers.reserve(thread_count); + + for (auto i = 0; i < thread_count; i++) { + auto worker = std::make_unique(); + worker->thread = std::thread(ThreadPool::worker_loop, this->state.get(), worker.get()); + this->state->workers.push_back(std::move(worker)); + } +} + +ThreadPool::ThreadPool(ThreadPool&& other) noexcept + : state(std::move(other.state)) { +} + +ThreadPool& ThreadPool::operator=(ThreadPool&& other) noexcept { + if (this != &other) { + this->stop(); + this->state = std::move(other.state); + } + return *this; +} + +size_t ThreadPool::thread_count() const noexcept { + if (this->state == nullptr) { + return 0; + } + + auto lk = std::shared_lock(this->state->worker_mutex); + return this->state->workers.size(); +} + +size_t ThreadPool::task_count() const noexcept { + if (this->state == nullptr) { + return 0; + } + + auto lk = std::shared_lock(this->state->queue_mutex); + return this->state->tasks.size(); +} + +void ThreadPool::stop() noexcept { + if (this->state == nullptr) { + return; + } + + this->state->stop(); +} + +ThreadPool::~ThreadPool() noexcept { + this->stop(); +} + +void ThreadPool::State::stop() noexcept { + bool expected = false; + if (!this->stopped.compare_exchange_strong(expected, true, std::memory_order_relaxed)) { + return; + } + + { + auto lk = std::unique_lock(this->queue_mutex); + auto _ = std::move(this->tasks); + lk.unlock(); + } + + { + auto lk = std::lock_guard(this->worker_mutex); + for (auto& worker : this->workers) { + worker->stop(); + } + } + + this->cv.notify_all(); + + for (auto& worker : this->workers) { + worker->clean_up(); + } +} + +ThreadPool::State::~State() noexcept { + this->stop(); +} + +void ThreadPool::Worker::stop() noexcept { + this->stopped.store(true, std::memory_order_relaxed); +} + +void ThreadPool::Worker::clean_up() noexcept { + this->stop(); + + if (this->thread.joinable()) { + try { + this->thread.join(); + } catch (...) { + } + } +} + +ThreadPool::Worker::~Worker() noexcept { + this->clean_up(); +} + +void ThreadPool::worker_loop(ThreadPool::State* state, ThreadPool::Worker* worker) { + while (true) { + ThreadPool::Task task; + { + auto lk = std::unique_lock(state->queue_mutex); + state->cv.wait(lk, [&]() { + return state->stopped.load(std::memory_order_relaxed) || worker->stopped.load(std::memory_order_relaxed) || !state->tasks.empty(); + }); + + if (state->stopped.load(std::memory_order_relaxed) || worker->stopped.load(std::memory_order_relaxed) || state->tasks.empty()) { + return; + } + task = std::move(state->tasks.front()); + state->tasks.pop(); + } + task(); + } +} +} // namespace threading diff --git a/packages/splat-transform-native/source/vcpkg-configuration.json b/packages/splat-transform-native/source/vcpkg-configuration.json new file mode 100644 index 0000000..1517256 --- /dev/null +++ b/packages/splat-transform-native/source/vcpkg-configuration.json @@ -0,0 +1,14 @@ +{ + "default-registry": { + "kind": "git", + "baseline": "ac7af7424cbaf9057cb246b620f455303dccd6ed", + "repository": "https://github.com/microsoft/vcpkg" + }, + "registries": [ + { + "kind": "artifact", + "location": "https://github.com/microsoft/vcpkg-ce-catalog/archive/refs/heads/main.zip", + "name": "microsoft" + } + ] +} diff --git a/packages/splat-transform-native/source/vcpkg.json b/packages/splat-transform-native/source/vcpkg.json new file mode 100644 index 0000000..5efe811 --- /dev/null +++ b/packages/splat-transform-native/source/vcpkg.json @@ -0,0 +1,11 @@ +{ + "dependencies": [ + "eigen3", + "nanoflann", + "libwebp", + { + "name": "libavif", + "features": ["aom"] + } + ] +} diff --git a/packages/splat-transform-native/splat-transform-darwin-arm64/dawn.node b/packages/splat-transform-native/splat-transform-darwin-arm64/dawn.node new file mode 100644 index 0000000..9551ad4 --- /dev/null +++ b/packages/splat-transform-native/splat-transform-darwin-arm64/dawn.node @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a523c583e9d0ed4134f909e09c0af0ce01b6833ec992c978f2d950850b89fb8 +size 9555800 diff --git a/packages/splat-transform-native/splat-transform-darwin-arm64/package.json b/packages/splat-transform-native/splat-transform-darwin-arm64/package.json new file mode 100644 index 0000000..43ff655 --- /dev/null +++ b/packages/splat-transform-native/splat-transform-darwin-arm64/package.json @@ -0,0 +1,15 @@ +{ + "name": "@manycore/aholo-splat-transform-darwin-arm64", + "version": "0.0.0", + "type": "commonjs", + "files": [ + "splat-transform.node", + "dawn.node" + ], + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ] +} diff --git a/packages/splat-transform-native/splat-transform-linux-arm64-gnu/dawn.node b/packages/splat-transform-native/splat-transform-linux-arm64-gnu/dawn.node new file mode 100644 index 0000000..3ce2383 --- /dev/null +++ b/packages/splat-transform-native/splat-transform-linux-arm64-gnu/dawn.node @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:211218cc2d80c19ac80604ff8684ffc87ae79bf34224b4f379272203a8e91ef1 +size 12373936 diff --git a/packages/splat-transform-native/splat-transform-linux-arm64-gnu/package.json b/packages/splat-transform-native/splat-transform-linux-arm64-gnu/package.json new file mode 100644 index 0000000..c22679c --- /dev/null +++ b/packages/splat-transform-native/splat-transform-linux-arm64-gnu/package.json @@ -0,0 +1,18 @@ +{ + "name": "@manycore/aholo-splat-transform-linux-arm64-gnu", + "version": "0.0.0", + "type": "commonjs", + "files": [ + "splat-transform.node", + "dawn.node" + ], + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ] +} diff --git a/packages/splat-transform-native/splat-transform-linux-x64-gnu/dawn.node b/packages/splat-transform-native/splat-transform-linux-x64-gnu/dawn.node new file mode 100644 index 0000000..a40a82a --- /dev/null +++ b/packages/splat-transform-native/splat-transform-linux-x64-gnu/dawn.node @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c2f65499115771f9355965294e694d5de6a33a6dbdde4187f3c5fbe64c7cc6f +size 12732032 diff --git a/packages/splat-transform-native/splat-transform-linux-x64-gnu/package.json b/packages/splat-transform-native/splat-transform-linux-x64-gnu/package.json new file mode 100644 index 0000000..8126dc2 --- /dev/null +++ b/packages/splat-transform-native/splat-transform-linux-x64-gnu/package.json @@ -0,0 +1,18 @@ +{ + "name": "@manycore/aholo-splat-transform-linux-x64-gnu", + "version": "0.0.0", + "type": "commonjs", + "files": [ + "splat-transform.node", + "dawn.node" + ], + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ] +} diff --git a/packages/splat-transform-native/splat-transform-win32-arm64-msvc/d3dcompiler_47.dll b/packages/splat-transform-native/splat-transform-win32-arm64-msvc/d3dcompiler_47.dll new file mode 100644 index 0000000..50567fd --- /dev/null +++ b/packages/splat-transform-native/splat-transform-win32-arm64-msvc/d3dcompiler_47.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1557adab24404308657d7902fdac82ce07a120987a849acab690ab402fecf449 +size 4741560 diff --git a/packages/splat-transform-native/splat-transform-win32-arm64-msvc/dawn.node b/packages/splat-transform-native/splat-transform-win32-arm64-msvc/dawn.node new file mode 100644 index 0000000..fed53df --- /dev/null +++ b/packages/splat-transform-native/splat-transform-win32-arm64-msvc/dawn.node @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8911cdf0988311a2c56eb2e9b8e6ecf89c9ce10133a396cc682f78751a0e4fc8 +size 11244544 diff --git a/packages/splat-transform-native/splat-transform-win32-arm64-msvc/dxcompiler.dll b/packages/splat-transform-native/splat-transform-win32-arm64-msvc/dxcompiler.dll new file mode 100644 index 0000000..53acef1 --- /dev/null +++ b/packages/splat-transform-native/splat-transform-win32-arm64-msvc/dxcompiler.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2eb48aa55b134f2fab98738fa45a1dc4322fda33c7ac99f387ac951f0a722c46 +size 21805568 diff --git a/packages/splat-transform-native/splat-transform-win32-arm64-msvc/dxil.dll b/packages/splat-transform-native/splat-transform-win32-arm64-msvc/dxil.dll new file mode 100644 index 0000000..99e8b11 --- /dev/null +++ b/packages/splat-transform-native/splat-transform-win32-arm64-msvc/dxil.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fda4dde92222ddb8564c3173ce48faa56f7e1c7b46b0b67dada1182ee07a4123 +size 1509800 diff --git a/packages/splat-transform-native/splat-transform-win32-arm64-msvc/package.json b/packages/splat-transform-native/splat-transform-win32-arm64-msvc/package.json new file mode 100644 index 0000000..233f762 --- /dev/null +++ b/packages/splat-transform-native/splat-transform-win32-arm64-msvc/package.json @@ -0,0 +1,18 @@ +{ + "name": "@manycore/aholo-splat-transform-win32-arm64-msvc", + "version": "0.0.0", + "type": "commonjs", + "files": [ + "splat-transform.node", + "dawn.node", + "d3dcompiler_47.dll", + "dxcompiler.dll", + "dxil.dll" + ], + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ] +} diff --git a/packages/splat-transform-native/splat-transform-win32-x64-msvc/d3dcompiler_47.dll b/packages/splat-transform-native/splat-transform-win32-x64-msvc/d3dcompiler_47.dll new file mode 100644 index 0000000..50567fd --- /dev/null +++ b/packages/splat-transform-native/splat-transform-win32-x64-msvc/d3dcompiler_47.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1557adab24404308657d7902fdac82ce07a120987a849acab690ab402fecf449 +size 4741560 diff --git a/packages/splat-transform-native/splat-transform-win32-x64-msvc/dawn.node b/packages/splat-transform-native/splat-transform-win32-x64-msvc/dawn.node new file mode 100644 index 0000000..34bb653 --- /dev/null +++ b/packages/splat-transform-native/splat-transform-win32-x64-msvc/dawn.node @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c1d23c084a131c13918ce950eed38910f672c5960aeb5049592703ba09ee302 +size 10427392 diff --git a/packages/splat-transform-native/splat-transform-win32-x64-msvc/dxcompiler.dll b/packages/splat-transform-native/splat-transform-win32-x64-msvc/dxcompiler.dll new file mode 100644 index 0000000..559407b --- /dev/null +++ b/packages/splat-transform-native/splat-transform-win32-x64-msvc/dxcompiler.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bf24ac4491dc4b8cd6fe2613df4618f0ad6428fbb1b3a3570333c676ab352af +size 20218880 diff --git a/packages/splat-transform-native/splat-transform-win32-x64-msvc/dxil.dll b/packages/splat-transform-native/splat-transform-win32-x64-msvc/dxil.dll new file mode 100644 index 0000000..99e8b11 --- /dev/null +++ b/packages/splat-transform-native/splat-transform-win32-x64-msvc/dxil.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fda4dde92222ddb8564c3173ce48faa56f7e1c7b46b0b67dada1182ee07a4123 +size 1509800 diff --git a/packages/splat-transform-native/splat-transform-win32-x64-msvc/package.json b/packages/splat-transform-native/splat-transform-win32-x64-msvc/package.json new file mode 100644 index 0000000..1b9a7ae --- /dev/null +++ b/packages/splat-transform-native/splat-transform-win32-x64-msvc/package.json @@ -0,0 +1,18 @@ +{ + "name": "@manycore/aholo-splat-transform-win32-x64-msvc", + "version": "0.0.0", + "type": "commonjs", + "files": [ + "splat-transform.node", + "dawn.node", + "d3dcompiler_47.dll", + "dxcompiler.dll", + "dxil.dll" + ], + "os": [ + "win32" + ], + "cpu": [ + "x64" + ] +} diff --git a/packages/splat-transform/.gitignore b/packages/splat-transform/.gitignore new file mode 100644 index 0000000..6c2fdfe --- /dev/null +++ b/packages/splat-transform/.gitignore @@ -0,0 +1,3 @@ +out +data + diff --git a/packages/splat-transform/CHANGELOG.md b/packages/splat-transform/CHANGELOG.md new file mode 100644 index 0000000..cf3e4ca --- /dev/null +++ b/packages/splat-transform/CHANGELOG.md @@ -0,0 +1,194 @@ +# Changelog + +## DEV + +- rename files, move workers to standalone directories +- make native implementation more stable + +## 1.6.3 + +- add `split` command +- replace `commander` with `yargs` +- use `Float32Array` instead of `Buffer` for splat output +- parallelize splat writes with workers +- add exception handle for native module + +## 1.6.2 + +- reduce lod write out memory overhead +- rename native api + +## 1.6.1 + +- allow import with extensions +- remove invalid data before merge + +## 1.6.0 + +- add inject custom task. + +## 1.5.2 + +- upgrade `fflate` to `^0.8.3` +- rebuild dawn with `D3D12`, `Vulkan` and `Metal` only. + +## 1.5.1 + +- Add a `compression` option for voxel binary output with `none`, `gzip`, and `zstd` modes. +- Improve voxel robustness with a `filterCluster` fallback for weak default seeds and automatic dense bounds for scale outliers. +- sync dawn builds + +## 1.4.2 + +- fix typo in `list:gpu` output + +## 1.4.1 + +- add `list:gpu` command + +## 1.4.0 + +- upgrade `commander` to `15.0.0` +- add `win32-arm64` pre build binary + +## 1.3.2 + +- try fix linux-arm64 issues by upgrade clang + +## 1.3.1 + +- fix block split + +## 1.3.0 + +- update license to MIT and OPEN SOURCE + +## 1.2.11 + +- Remove the `webgpu` dependency and switch to a self-built version. +- Optimize `k-means` computation with `vec4` alignment and simplified Euclidean distance calculation. Performance improves by about 50%. +- Optimize `clusterAverage` to reduce JavaScript-side overhead. +- Share the native thread pool. + +## 1.2.10 + +- Add a prebuilt `osx-arm64` package. + +## 1.2.9 + +- Improve the coding style by enabling `verbatimModuleSyntax` and `isolatedModules`, and migrate to `OXC`. +- Improve native module loading. +- Update the voxel output format version to 1.2. Add gzip compression and a compact encoding mode; both are disabled by default. +- Enable `filterCluster` prefiltering for voxel output by default. + +## 1.2.8 + +- Upgrade to `typescript@^6.0.3`. +- Prevent `WebP` encoding from compressing fully transparent data. +- Add support for `spz v4` input and output. The default remains v3. +- Add support for `esz` input and output. + +## 1.2.7 + +- Add `maxShDegree` parameter support to `ReadTask`. + +## 1.2.6 + +- Add a CPU implementation for voxelization and optimize several algorithms. +- Optimize the voxel output format. + +## 1.2.5 + +- Fix a LOD construction error when `SplatData.counts = 0`. + +## 1.2.4 + +- Add support for generating voxel colliders. +- Add multitask support for AVIF encoding and decoding. +- Build the Linux version with glibc. +- Fix LOD chunk generation failures for small files. +- Optimize the logger. + +## 1.2.3 + +- Optimize internal code and remove unused implementations. +- Update the `chunk-lod` forward box calculation to match application requirements. + +## 1.2.2 + +- Add `libavif`. +- Reorganize the file structure and remove the `IData` structure. +- Fix delete failures caused by conflicts between `modify` and `chunk-lod`. + +## 1.2.1 + +- Add `GPU` device selection support. +- Add `MortonSort` support to `write` to improve the compression ratio. + +## 1.2.0 + +- Refactor CLI commands. +- Change the underlying implementation to composable pipeline tasks, reducing the number of saved files. +- Use double precision for intermediate calculations and remove invalid code. +- Add `getOrCreateDevice` for sharing GPU devices. +- Improve `auto-lod` so it stays closer to the target value. +- Optimize output. + +## 1.1.2 + +- Add the nanogs search algorithm. +- Add the `--max-chunk-counts` parameter. + +## 1.1.1 + +- Optimize LOD parameters to avoid long-running execution. + +## 1.1.0 + +- Update CLI invocation parameters. +- Optimize `lod:auto` to avoid OOM issues caused by delayed memory release. +- Add the `lod:auto-chunk` command. +- Add exponential stepping support for multi-level LOD. +- Optimize `auto-chunk` output. + +## 1.0.8 + +- Optimize Gaussian spatial partitioning. +- Optimize Gaussian bounding boxes and remove data with excessive offsets. +- Fix incorrect `quality` parsing for lossy `webP` encoding. +- Fix LOD command errors. +- Optimize `cluster_average` parallel granularity. + +## 1.0.7 + +- Add support for SOG output. + +## 1.0.6 + +- Optimize built-in LOD parameters. + +## 1.0.5 + +- Optimize built-in LOD parameters. + +## 1.0.4 + +- Fix an error when `create` parses `deletedIndicesBitMap`. + +## 1.0.3 + +- Optimize `autoLod` results and implementation. + +## 1.0.2 + +- Add streaming parsing and writing support to reduce memory usage. +- Add `autoLod` support for generating LOD results from 3DGS data. + - `splat-transform lod --type auto --ratio 0.3 simiao.ply simiao-lod.spz` + +## 1.0.1 + +- Add `bin.est` to avoid conflicts when used together with `@playcanvas/splat-transform`. + +## 1.0.0 + +- Publish the first stable package. diff --git a/packages/splat-transform/LICENSE b/packages/splat-transform/LICENSE new file mode 100644 index 0000000..8d6424f --- /dev/null +++ b/packages/splat-transform/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright © 2026 MANYCORE, INC. + +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/packages/splat-transform/README.md b/packages/splat-transform/README.md new file mode 100644 index 0000000..ca7be42 --- /dev/null +++ b/packages/splat-transform/README.md @@ -0,0 +1,40 @@ +# splat-transform + +A 3DGS modifier used by aholo + +## Requirement + +- node >= 22.22.1 +- system + - windows: windows 22H2+, x86_64, ARM64, D3D12 or Vulkan compatible GPU(When use GPU features, dedicated GPU for better performance) + - linux: x86_64, ARM64, glibc >= 2.34, libstdc++ >= 3.4.30, Vulkan compatible GPU(When use GPU features, dedicated GPU for better performance) + - osx: apple silicon ARM64 only. + +## Usage + +```bash +npm install @manycore/aholo-splat-transform -g + +splat-transform --help + +Execute a task pipeline from configuration file + +Arguments: + path pipeline config filepath + +Options: + -V, --version output the version number + -h, --help display help for command + +Commands: + create Merge & Transform gaussian splat file + lod:loading [options] Generate loading-lod for gaussian splat file + lod:flex [options] Generate flex-lod for gaussian splat file + lod:auto [options] Generate auto-lod for gaussian splat file + lod:auto-chunk [options] Generate auto-chunk-lod for gaussian splat file + list:gpu List all available gpu adapters +``` + +## License + +[MIT License](./LICENSE). diff --git a/packages/splat-transform/THIRD_PARTY_LICENSES.txt b/packages/splat-transform/THIRD_PARTY_LICENSES.txt new file mode 100644 index 0000000..f179c3a --- /dev/null +++ b/packages/splat-transform/THIRD_PARTY_LICENSES.txt @@ -0,0 +1,1373 @@ +This software is based in part on the work of the Independent JPEG Group. + +**Eigen** + +The Eigen library is licensed under the Mozilla Public License 2.0. +You may obtain the source code for Eigen at https://gitlab.com/libeigen/eigen. + +COPYING.README: + +Eigen is primarily MPL2 licensed. See COPYING.MPL2 and these links: + http://www.mozilla.org/MPL/2.0/ + http://www.mozilla.org/MPL/2.0/FAQ.html + +Some files contain third-party code under BSD or other MPL2-compatible licenses, +whence the other COPYING.* files here. + +COPYING.APACHE: + +/* + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +COPYING.BSD: + +/* + Copyright (c) 2011, Intel Corporation. All rights reserved. + + Redistribution and use in source and binary forms, with or without modification, + are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of Intel Corporation nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + + +COPYING.MINPACK: + +Minpack Copyright Notice (1999) University of Chicago. All rights reserved + +Redistribution and use in source and binary forms, with or +without modification, are permitted provided that the +following conditions are met: + +1. Redistributions of source code must retain the above +copyright notice, this list of conditions and the following +disclaimer. + +2. Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following +disclaimer in the documentation and/or other materials +provided with the distribution. + +3. The end-user documentation included with the +redistribution, if any, must include the following +acknowledgment: + + "This product includes software developed by the + University of Chicago, as Operator of Argonne National + Laboratory. + +Alternately, this acknowledgment may appear in the software +itself, if and wherever such third-party acknowledgments +normally appear. + +4. WARRANTY DISCLAIMER. THE SOFTWARE IS SUPPLIED "AS IS" +WITHOUT WARRANTY OF ANY KIND. THE COPYRIGHT HOLDER, THE +UNITED STATES, THE UNITED STATES DEPARTMENT OF ENERGY, AND +THEIR EMPLOYEES: (1) DISCLAIM ANY WARRANTIES, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO ANY IMPLIED WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE +OR NON-INFRINGEMENT, (2) DO NOT ASSUME ANY LEGAL LIABILITY +OR RESPONSIBILITY FOR THE ACCURACY, COMPLETENESS, OR +USEFULNESS OF THE SOFTWARE, (3) DO NOT REPRESENT THAT USE OF +THE SOFTWARE WOULD NOT INFRINGE PRIVATELY OWNED RIGHTS, (4) +DO NOT WARRANT THAT THE SOFTWARE WILL FUNCTION +UNINTERRUPTED, THAT IT IS ERROR-FREE OR THAT ANY ERRORS WILL +BE CORRECTED. + +5. LIMITATION OF LIABILITY. IN NO EVENT WILL THE COPYRIGHT +HOLDER, THE UNITED STATES, THE UNITED STATES DEPARTMENT OF +ENERGY, OR THEIR EMPLOYEES: BE LIABLE FOR ANY INDIRECT, +INCIDENTAL, CONSEQUENTIAL, SPECIAL OR PUNITIVE DAMAGES OF +ANY KIND OR NATURE, INCLUDING BUT NOT LIMITED TO LOSS OF +PROFITS OR LOSS OF DATA, FOR ANY REASON WHATSOEVER, WHETHER +SUCH LIABILITY IS ASSERTED ON THE BASIS OF CONTRACT, TORT +(INCLUDING NEGLIGENCE OR STRICT LIABILITY), OR OTHERWISE, +EVEN IF ANY OF SAID PARTIES HAS BEEN WARNED OF THE +POSSIBILITY OF SUCH LOSS OR DAMAGES. + + +COPYING.MPL2: + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + +------------------------------------------------------------------------------ + +**nanoflann** + +Software License Agreement (BSD License) + +Copyright 2008-2009 Marius Muja (mariusm@cs.ubc.ca). All rights reserved. +Copyright 2008-2009 David G. Lowe (lowe@cs.ubc.ca). All rights reserved. +Copyright 2011 Jose L. Blanco (joseluisblancoc@gmail.com). All rights reserved. + +THE BSD LICENSE + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------ + +**aom** + +Copyright (c) 2016, Alliance for Open Media. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------ + +**libavif** + +Copyright 2019 Joe Drago. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------ + +Files: src/obu.c + +Copyright © 2018-2019, VideoLAN and dav1d authors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------ + +Files: third_party/iccjpeg/* + +In plain English: + +1. We don't promise that this software works. (But if you find any bugs, + please let us know!) +2. You can use this software for whatever you want. You don't have to pay us. +3. You may not pretend that you wrote this software. If you use it in a + program, you must acknowledge somewhere in your documentation that + you've used the IJG code. + +In legalese: + +The authors make NO WARRANTY or representation, either express or implied, +with respect to this software, its quality, accuracy, merchantability, or +fitness for a particular purpose. This software is provided "AS IS", and you, +its user, assume the entire risk as to its quality and accuracy. + +This software is copyright (C) 1991-2013, Thomas G. Lane, Guido Vollbeding. +All Rights Reserved except as specified below. + +Permission is hereby granted to use, copy, modify, and distribute this +software (or portions thereof) for any purpose, without fee, subject to these +conditions: +(1) If any part of the source code for this software is distributed, then this +README file must be included, with this copyright and no-warranty notice +unaltered; and any additions, deletions, or changes to the original files +must be clearly indicated in accompanying documentation. +(2) If only executable code is distributed, then the accompanying +documentation must state that "this software is based in part on the work of +the Independent JPEG Group". +(3) Permission for use of this software is granted only if the user accepts +full responsibility for any undesirable consequences; the authors accept +NO LIABILITY for damages of any kind. + +These conditions apply to any software derived from or based on the IJG code, +not just to the unmodified library. If you use our work, you ought to +acknowledge us. + +Permission is NOT granted for the use of any IJG author's name or company name +in advertising or publicity relating to this software or products derived from +it. This software may be referred to only as "the Independent JPEG Group's +software". + +We specifically permit and encourage the use of this software as the basis of +commercial products, provided that all warranty or liability claims are +assumed by the product vendor. + + +The Unix configuration script "configure" was produced with GNU Autoconf. +It is copyright by the Free Software Foundation but is freely distributable. +The same holds for its supporting scripts (config.guess, config.sub, +ltmain.sh). Another support script, install-sh, is copyright by X Consortium +but is also freely distributable. + +The IJG distribution formerly included code to read and write GIF files. +To avoid entanglement with the Unisys LZW patent, GIF reading support has +been removed altogether, and the GIF writer has been simplified to produce +"uncompressed GIFs". This technique does not use the LZW algorithm; the +resulting GIF files are larger than usual, but are readable by all standard +GIF decoders. + +We are required to state that + "The Graphics Interchange Format(c) is the Copyright property of + CompuServe Incorporated. GIF(sm) is a Service Mark property of + CompuServe Incorporated." + +------------------------------------------------------------------------------ + +Files: contrib/gdk-pixbuf/* + +Copyright 2020 Emmanuel Gil Peyrot. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------ + +Files: android_jni/gradlew* + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +------------------------------------------------------------------------------ + +Files: third_party/libyuv/* + +Copyright 2011 The LibYuv Project Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------ + +**WebP** + +COPYING: + +Copyright (c) 2010, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + +PATENTS: + +Additional IP Rights Grant (Patents) +------------------------------------ + +"These implementations" means the copyrightable works that implement the WebM +codecs distributed by Google as part of the WebM Project. + +Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge, +royalty-free, irrevocable (except as stated in this section) patent license to +make, have made, use, offer to sell, sell, import, transfer, and otherwise +run, modify and propagate the contents of these implementations of WebM, where +such license applies only to those patent claims, both currently owned by +Google and acquired in the future, licensable by Google that are necessarily +infringed by these implementations of WebM. This grant does not include claims +that would be infringed only as a consequence of further modification of these +implementations. If you or your agent or exclusive licensee institute or order +or agree to the institution of patent litigation or any other patent +enforcement activity against any entity (including a cross-claim or +counterclaim in a lawsuit) alleging that any of these implementations of WebM +or any code incorporated within any of these implementations of WebM +constitute direct or contributory patent infringement, or inducement of +patent infringement, then any patent rights granted to you under this License +for these implementations of WebM shall terminate as of the date such +litigation is filed. + +------------------------------------------------------------------------------ + +**libyuv** + +Copyright 2011 The LibYuv Project Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------------------------------------------------------------------------------ + +**libjpeg-turbo** + +libjpeg-turbo Licenses +====================== + +libjpeg-turbo is covered by two compatible BSD-style open source licenses: + +- The IJG (Independent JPEG Group) License, which is listed in + [README.ijg](README.ijg) + + This license applies to the libjpeg API library and associated programs, + including any code inherited from libjpeg and any modifications to that + code. Note that the libjpeg-turbo SIMD source code bears the + [zlib License](https://opensource.org/licenses/Zlib), but in the context of + the overall libjpeg API library, the terms of the zlib License are subsumed + by the terms of the IJG License. + +- The Modified (3-clause) BSD License, which is listed below + + This license applies to the TurboJPEG API library and associated programs, as + well as the build system. Note that the TurboJPEG API library wraps the + libjpeg API library, so in the context of the overall TurboJPEG API library, + both the terms of the IJG License and the terms of the Modified (3-clause) + BSD License apply. + + +Complying with the libjpeg-turbo Licenses +========================================= + +This section provides a roll-up of the libjpeg-turbo licensing terms, to the +best of our understanding. This is not a license in and of itself. It is +intended solely for clarification. + +1. If you are distributing a modified version of the libjpeg-turbo source, + then: + + 1. You cannot alter or remove any existing copyright or license notices + from the source. + + **Origin** + - Clause 1 of the IJG License + - Clause 1 of the Modified BSD License + - Clauses 1 and 3 of the zlib License + + 2. You must add your own copyright notice to the header of each source + file you modified, so others can tell that you modified that file. (If + there is not an existing copyright header in that file, then you can + simply add a notice stating that you modified the file.) + + **Origin** + - Clause 1 of the IJG License + - Clause 2 of the zlib License + + 3. You must include the IJG README file, and you must not alter any of the + copyright or license text in that file. + + **Origin** + - Clause 1 of the IJG License + +2. If you are distributing only libjpeg-turbo binaries without the source, or + if you are distributing an application that statically links with + libjpeg-turbo, then: + + 1. Your product documentation must include a message stating: + + This software is based in part on the work of the Independent JPEG + Group. + + **Origin** + - Clause 2 of the IJG license + + 2. If your binary distribution includes or uses the TurboJPEG API, then + your product documentation must include the text of the Modified BSD + License (see below.) + + **Origin** + - Clause 2 of the Modified BSD License + +3. You cannot use the name of the IJG or The libjpeg-turbo Project or the + contributors thereof in advertising, publicity, etc. + + **Origin** + - IJG License + - Clause 3 of the Modified BSD License + +4. The IJG and The libjpeg-turbo Project do not warrant libjpeg-turbo to be + free of defects, nor do we accept any liability for undesirable + consequences resulting from your use of the software. + + **Origin** + - IJG License + - Modified BSD License + - zlib License + + +The Modified (3-clause) BSD License +=================================== + +Copyright (C)2009-2025 D. R. Commander. All Rights Reserved.
+Copyright (C)2015 Viktor Szathmáry. All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +- Neither the name of the libjpeg-turbo Project nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS", +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + + +Why Two Licenses? +================= + +The zlib License could have been used instead of the Modified (3-clause) BSD +License, and since the IJG License effectively subsumes the distribution +conditions of the zlib License, this would have effectively placed +libjpeg-turbo binary distributions under the IJG License. However, the IJG +License specifically refers to the Independent JPEG Group and does not extend +attribution and endorsement protections to other entities. Thus, it was +desirable to choose a license that granted us the same protections for new code +that were granted to the IJG for code derived from their software. + diff --git a/packages/splat-transform/bin/cli.js b/packages/splat-transform/bin/cli.js new file mode 100644 index 0000000..4194fd4 --- /dev/null +++ b/packages/splat-transform/bin/cli.js @@ -0,0 +1,199 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import yargs from 'yargs/yargs'; +import { hideBin } from 'yargs/helpers'; +import packageJson from '../package.json' with { type: 'json' }; +import { run } from '../dist/index.js'; +import { enumerateAdapters } from '../dist/utils/index.js'; + +const ExtraText = ` +Transform Gaussian splats file +=================================== +SUPPORTED INPUTS + .ply .compressed.ply .sog meta.json .ksplat .splat .spz .lcc .esz + +SUPPORTED OUTPUTS + .ply .spz .uspz .splat .sog .esz +`; + +function readJsonConfig(config) { + const content = fs.readFileSync(config, { encoding: 'utf-8' }); + return JSON.parse(content); +} + +const cli = yargs(hideBin(process.argv)) + .scriptName('splat-transform') + .version(packageJson.version) + .showHelpOnFail(false) + .parserConfiguration({ + 'boolean-negation': false, + 'duplicate-arguments-array': false, + }) + .alias('h', 'help') + .alias('v', 'version') + .usage(`${ExtraText}\n$0 `) + .strict() + .command({ + command: '$0 ', + describe: 'Execute a task pipeline from configuration file', + builder(argv) { + argv.positional('config', { describe: 'pipeline config file', type: 'string' }); + }, + async handler(argv) { + await run(readJsonConfig(argv.config)); + }, + }) + .command('create ', false, { + describe: 'Merge & Transform gaussian splat file', + builder(argv) { + argv.positional('input', { describe: 'input file', type: 'string' }).positional('output', { + describe: 'output file', + type: 'string', + }); + }, + async handler(argv) { + await run({ + version: 1, + tasks: [ + { id: '0', type: 'Read', config: { inputs: [argv.input], output: 'cache0' } }, + { id: '1', type: 'Modify', config: { input: 'cache0', output: 'cache0' } }, + { id: '2', type: 'Write', config: { input: 'cache0', output: argv.output } }, + ], + }); + }, + }) + .command('lod:loading ', false, { + describe: 'Generate loading-lod for gaussian splat file', + builder(argv) { + argv.option('ratio', { describe: 'ratio', type: 'number' }) + .positional('input', { describe: 'input filepath', type: 'string' }) + .positional('output', { describe: 'output filepath', type: 'string' }); + }, + async handler(argv) { + await run({ + version: 1, + tasks: [ + { id: '0', type: 'Read', config: { inputs: [argv.input], output: 'cache0' } }, + { id: '1', type: 'SkeletonLod', config: { input: 'cache0', output: 'cache0', ratio: argv.ratio } }, + { id: '2', type: 'Write', config: { input: 'cache0', output: argv.output } }, + ], + }); + }, + }) + .command('lod:flex ', false, { + describe: 'Generate flex-lod for gaussian splat file', + builder(argv) { + argv.option('score', { demandOption: true, type: 'string' }) + .option('ratio', { describe: 'ratio', type: 'number' }) + .positional('input', { describe: 'input filepath', type: 'string' }) + .positional('output', { describe: 'output filepath', type: 'string' }); + }, + async handler(argv) { + await run({ + version: 1, + tasks: [ + { id: '0', type: 'Read', config: { inputs: [argv.input], output: 'cache0' } }, + { + id: '1', + type: 'FlexLod', + config: { input: 'cache0', output: 'cache0', ratio: argv.ratio, scorePath: argv.score }, + }, + { id: '2', type: 'Write', config: { input: 'cache0', output: argv.output } }, + ], + }); + }, + }) + .command('lod:auto ', false, { + describe: 'Generate auto-lod for gaussian splat file', + builder(argv) { + argv.option('ratio', { describe: 'ratio', type: 'number' }) + .positional('input', { describe: 'input filepath', type: 'string' }) + .positional('output', { describe: 'output filepath', type: 'string' }); + }, + async handler(argv) { + await run({ + version: 1, + tasks: [ + { id: '0', type: 'Read', config: { inputs: [argv.input], output: 'cache0' } }, + { id: '1', type: 'AutoLod', config: { input: 'cache0', output: 'cache0', ratio: argv.ratio } }, + { id: '2', type: 'Write', config: { input: 'cache0', output: argv.output } }, + ], + }); + }, + }) + .command('lod:auto-chunk ', false, { + describe: 'Generate auto-chunk-lod for gaussian splat file', + builder(argv) { + argv.option('type', { + alias: 't', + choices: ['ply', 'spz', 'splat', 'sog', 'esz'], + demandOption: true, + describe: 'output file type', + type: 'string', + }) + .option('max-chunk-counts', { describe: 'max chunk counts', type: 'number' }) + .positional('input', { describe: 'input filepath', type: 'string' }) + .positional('output', { describe: 'output directory', type: 'string' }); + }, + async handler(argv) { + await run({ + version: 1, + tasks: [ + { id: '0', type: 'Read', config: { inputs: [argv.input], output: 'cache0' } }, + { + id: '1', + type: 'AutoChunkLod', + config: { + input: 'cache0', + output: 'cache0', + type: argv.type, + maxChunkCounts: argv.maxChunkCounts, + }, + }, + { id: '2', type: 'Write', config: { input: 'cache0', output: argv.output } }, + ], + }); + }, + }) + .command('split ', false, { + describe: 'Split splat file into blocks', + builder(argv) { + argv.option('type', { + alias: 't', + choices: ['ply', 'spz', 'splat', 'sog'], + demandOption: true, + describe: 'output file type', + type: 'string', + }) + .option('precision', { describe: 'precision', type: 'number' }) + .positional('input', { describe: 'input filepath', type: 'string' }) + .positional('output', { describe: 'output directory', type: 'string' }); + }, + async handler(argv) { + await run({ + version: 1, + tasks: [ + { id: '0', type: 'Read', config: { inputs: [argv.input], output: 'cache0' } }, + { + id: '1', + type: 'SplitSplatTask', + config: { input: 'cache0', output: 'cache0', type: argv.type, blockPrecision: argv.precision }, + }, + { id: '2', type: 'Write', config: { input: 'cache0', output: argv.output } }, + ], + }); + }, + }) + .command('list:gpu', false, { + describe: 'List all available gpu adapters', + builder() {}, + async handler() { + const adapters = await enumerateAdapters(); + const alignment = Math.ceil(Math.log10(adapters.length)); + for (const adapter of adapters) { + console.log(`Adapter ${adapter.index.toString().padStart(alignment, ' ')}: ${adapter.name}`); + } + }, + }); + +await cli.parseAsync(); diff --git a/packages/splat-transform/package.json b/packages/splat-transform/package.json new file mode 100644 index 0000000..7630daa --- /dev/null +++ b/packages/splat-transform/package.json @@ -0,0 +1,67 @@ +{ + "name": "@manycore/aholo-splat-transform", + "version": "1.6.3", + "description": "Transform & filter Gaussian splats file", + "author": "egs", + "repository": { + "type": "git", + "url": "https://github.com/manycoretech/aholo-viewer.git", + "directory": "packages/splat-transform" + }, + "license": "MIT", + "engines": { + "node": ">= 22.22.1" + }, + "type": "module", + "bin": { + "splat-transform": "bin/cli.js" + }, + "keywords": [ + "aholo", + "cli", + "3d-gaussian-splatting", + "gaussian-splatting" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./*.js": { + "import": "./dist/*.js", + "types": "./dist/*.d.ts" + }, + "./*": { + "import": "./dist/*.js", + "types": "./dist/*.d.ts" + } + }, + "files": [ + "dist/", + "CHANGELOG.md", + "THIRD_PARTY_LICENSES.txt" + ], + "scripts": { + "build": "node ./scripts/build.js build" + }, + "dependencies": { + "fflate": "^0.8.3", + "tslib": "^2.8.1", + "yargs": "^18.0.0" + }, + "optionalDependencies": { + "@manycore/aholo-splat-transform-darwin-arm64": "workspace:*", + "@manycore/aholo-splat-transform-linux-arm64-gnu": "workspace:*", + "@manycore/aholo-splat-transform-linux-x64-gnu": "workspace:*", + "@manycore/aholo-splat-transform-win32-arm64-msvc": "workspace:*", + "@manycore/aholo-splat-transform-win32-x64-msvc": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "@webgpu/types": "^0.1.71", + "typescript": "^6.0.3" + } +} diff --git a/packages/splat-transform/pipeline.json b/packages/splat-transform/pipeline.json new file mode 100644 index 0000000..4c71cb0 --- /dev/null +++ b/packages/splat-transform/pipeline.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "tasks": [ + { + "id": "0", + "type": "Read", + "config": { + "inputs": [""], + "output": "cache0" + } + }, + { + "id": "1", + "type": "AutoChunkLod", + "config": { + "input": "cache0", + "output": "cache0", + "type": "spz", + "maxChunkCounts": "400000" + } + }, + { + "id": "2", + "type": "Write", + "config": { + "input": "cache0", + "output": "" + } + } + ] +} diff --git a/packages/splat-transform/scripts/build.js b/packages/splat-transform/scripts/build.js new file mode 100644 index 0000000..5282b44 --- /dev/null +++ b/packages/splat-transform/scripts/build.js @@ -0,0 +1,29 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import yargs from 'yargs/yargs'; +import { hideBin } from 'yargs/helpers'; +import { execCommand } from '@internal/utils/process.js'; + +const __dirname = import.meta.dirname; + +async function build() { + await fs.rm('./dist', { recursive: true, force: true }); + await execCommand('tsc', { + env: { + ...process.env, + PATH: path.join(__dirname, '../node_modules/.bin') + path.delimiter + process.env.PATH, + }, + }).promise; +} + +await yargs(hideBin(process.argv)) + .scriptName('build') + .showHelpOnFail(false) + .strict() + .command({ + command: ['build', '$0'], + async handler() { + await build(); + }, + }) + .parseAsync(); diff --git a/packages/splat-transform/src/SplatData.ts b/packages/splat-transform/src/SplatData.ts new file mode 100644 index 0000000..d0da581 --- /dev/null +++ b/packages/splat-transform/src/SplatData.ts @@ -0,0 +1,231 @@ +import { Quaternion, deferred, type ISplatData } from './utils/index.js'; +import { SH_MAPS } from './constant.js'; + +export interface ISingleSplat { + x: number; + y: number; + z: number; + sx: number; + sy: number; + sz: number; + qx: number; + qy: number; + qz: number; + qw: number; + r: number; + g: number; + b: number; + a: number; + shN: number[]; +} + +export enum ColIdx { + x = 0, + y = 1, + z = 2, + sx = 3, + sy = 4, + sz = 5, + qx = 6, + qy = 7, + qz = 8, + qw = 9, + r = 10, + g = 11, + b = 12, + a = 13, + shOffset = 14, +} + +const tempQuat = new Quaternion(0, 0, 0, 1); +export class SplatData { + counts: number; + shDegree: number; + shCounts: number; + maxShDegree: number; + table: Float32Array[]; + + constructor(blockCounts: number = 1, maxShDegree: number = 3) { + this.blockCounts = blockCounts; + this.maxShDegree = maxShDegree; + } + + blockOffsets: number[] = []; + blockContentCounts: number[] = []; + private blockCounts: number; + private totalBlockCounts: number = 0; + private totalBlockShDegree: number = 3; + private blockExecs: Function[] = []; + private currentBlockIndex: number = 0; + initBlock(counts: number, shDegree: number) { + this.blockContentCounts.push(counts); + this.blockOffsets.push(this.totalBlockCounts); + this.totalBlockCounts += counts; + this.totalBlockShDegree = Math.min(shDegree, this.totalBlockShDegree); + const { promise, resolve } = deferred(); + this.blockExecs.push(resolve); + if (this.blockOffsets.length === this.blockCounts) { + this.init(this.totalBlockCounts, this.totalBlockShDegree); + this.blockExecs[this.currentBlockIndex](this.blockOffsets[0]); + } + return promise; + } + + finishBlock() { + this.currentBlockIndex++; + this.blockExecs[this.currentBlockIndex]?.(this.blockOffsets[this.currentBlockIndex]); + } + + init(counts: number, shDegree: number) { + this.counts = counts; + this.shDegree = Math.min(shDegree, this.maxShDegree); + const shCounts = (this.shCounts = SH_MAPS[this.shDegree]); + this.table = new Array(14 + shCounts).fill(0).map(() => new Float32Array(counts)); + return this; + } + + set(i: number, single: ISingleSplat) { + const { table } = this; + + table[ColIdx.x][i] = single.x; + table[ColIdx.y][i] = single.y; + table[ColIdx.z][i] = single.z; + + table[ColIdx.sx][i] = single.sx; + table[ColIdx.sy][i] = single.sy; + table[ColIdx.sz][i] = single.sz; + + tempQuat.set(single.qx, single.qy, single.qz, single.qw).normalize(); + table[ColIdx.qx][i] = tempQuat.x; + table[ColIdx.qy][i] = tempQuat.y; + table[ColIdx.qz][i] = tempQuat.z; + table[ColIdx.qw][i] = tempQuat.w; + + table[ColIdx.r][i] = single.r; + table[ColIdx.g][i] = single.g; + table[ColIdx.b][i] = single.b; + table[ColIdx.a][i] = single.a; + } + + setCenter(i: number, x: number, y: number, z: number) { + const { table } = this; + table[ColIdx.x][i] = x; + table[ColIdx.y][i] = y; + table[ColIdx.z][i] = z; + } + + setScale(i: number, sx: number, sy: number, sz: number) { + const { table } = this; + table[ColIdx.sx][i] = sx; + table[ColIdx.sy][i] = sy; + table[ColIdx.sz][i] = sz; + } + + setQuat(i: number, qx: number, qy: number, qz: number, qw: number) { + const { table } = this; + tempQuat.set(qx, qy, qz, qw).normalize(); + table[ColIdx.qx][i] = tempQuat.x; + table[ColIdx.qy][i] = tempQuat.y; + table[ColIdx.qz][i] = tempQuat.z; + table[ColIdx.qw][i] = tempQuat.w; + } + + setColor(i: number, r: number, g: number, b: number) { + const { table } = this; + table[ColIdx.r][i] = r; + table[ColIdx.g][i] = g; + table[ColIdx.b][i] = b; + } + + setAlpha(i: number, a: number) { + const { table } = this; + table[ColIdx.a][i] = a; + } + + setShN(i: number, shN: number[]) { + const { table, shCounts } = this; + for (let j = 0; j < shCounts; j++) { + table[ColIdx.shOffset + j][i] = shN[j]; + } + } + + get(i: number, single: ISingleSplat) { + const { table } = this; + single.x = table[ColIdx.x][i]; + single.y = table[ColIdx.y][i]; + single.z = table[ColIdx.z][i]; + single.sx = table[ColIdx.sx][i]; + single.sy = table[ColIdx.sy][i]; + single.sz = table[ColIdx.sz][i]; + single.qx = table[ColIdx.qx][i]; + single.qy = table[ColIdx.qy][i]; + single.qz = table[ColIdx.qz][i]; + single.qw = table[ColIdx.qw][i]; + single.r = table[ColIdx.r][i]; + single.g = table[ColIdx.g][i]; + single.b = table[ColIdx.b][i]; + single.a = table[ColIdx.a][i]; + } + + getCenter(i: number, single: ISingleSplat) { + const { table } = this; + single.x = table[ColIdx.x][i]; + single.y = table[ColIdx.y][i]; + single.z = table[ColIdx.z][i]; + } + + getScale(i: number, single: ISingleSplat) { + const { table } = this; + single.sx = table[ColIdx.sx][i]; + single.sy = table[ColIdx.sy][i]; + single.sz = table[ColIdx.sz][i]; + } + + getQuat(i: number, single: ISingleSplat) { + const { table } = this; + single.qx = table[ColIdx.qx][i]; + single.qy = table[ColIdx.qy][i]; + single.qz = table[ColIdx.qz][i]; + single.qw = table[ColIdx.qw][i]; + } + + getColor(i: number, single: ISingleSplat) { + const { table } = this; + single.r = table[ColIdx.r][i]; + single.g = table[ColIdx.g][i]; + single.b = table[ColIdx.b][i]; + } + + getAlpha(i: number, single: ISingleSplat) { + const { table } = this; + single.a = table[ColIdx.a][i]; + } + + getShN(i: number, shN: number[]) { + const { shCounts, table } = this; + for (let j = 0; j < shCounts; j++) { + shN[j] = table[ColIdx.shOffset + j][i]; + } + } + + destroy() { + this.counts = 0; + this.table = []; + } + + serialize(): ISplatData { + return { + counts: this.counts, + shDegree: this.shDegree, + table: this.table, + }; + } + + deserialize(data: ISplatData) { + const { counts, shDegree, table } = data; + this.counts = counts; + this.shDegree = shDegree; + this.shCounts = SH_MAPS[shDegree]; + this.table = table; + } +} diff --git a/packages/splat-transform/src/constant.ts b/packages/splat-transform/src/constant.ts new file mode 100644 index 0000000..f01ed39 --- /dev/null +++ b/packages/splat-transform/src/constant.ts @@ -0,0 +1,15 @@ +export const SH_C0 = 0.28209479177387814; + +export const SH_MAPS: Record = { + 0: 0, + 1: 9, + 2: 24, + 3: 45, +}; + +export const NUM_F_REST_TO_SH_DEGREE: Record = { + 0: 0, + 9: 1, + 24: 2, + 45: 3, +}; diff --git a/packages/splat-transform/src/file/IFile.ts b/packages/splat-transform/src/file/IFile.ts new file mode 100644 index 0000000..2b2d6fe --- /dev/null +++ b/packages/splat-transform/src/file/IFile.ts @@ -0,0 +1,6 @@ +import type { SplatData } from '../SplatData.js'; + +export interface IFile { + read(stream: ReadableStream, contentLength: number, data: SplatData): Promise; + write(stream: WritableStream, data: SplatData, indices?: Uint32Array): Promise; +} diff --git a/packages/splat-transform/src/file/esz.ts b/packages/splat-transform/src/file/esz.ts new file mode 100644 index 0000000..9dc8bd3 --- /dev/null +++ b/packages/splat-transform/src/file/esz.ts @@ -0,0 +1,430 @@ +import { unzipSync, type Zippable, zipSync } from 'fflate'; +import { ColIdx, type ISingleSplat, type SplatData } from '../SplatData.js'; +import { clamp, extractFromRootDir, isUrl, logger, mortonSort } from '../utils/index.js'; +import type { IFile } from './IFile.js'; +import { decodeWebP, encodeWebP, WebPLosslessProfile } from '../native/index.js'; +import { SH_C0, SH_MAPS } from '../constant.js'; + +interface Metadata { + version: number; + counts: number; + shDegree: number; + box: { + min: [number, number, number]; + max: [number, number, number]; + }; + resources: { + means_l: string; + means_u: string; + scales: string; + quats: string; + sh0: string; + shN?: string; + }; +} + +const TEMP_ROT: number[] = new Array(4); +const PERM_TABLE = [ + // original quat idx ---> actual storage idx + [0, 1, 2, 3], + [3, 1, 2, 0], + [1, 3, 2, 0], + [1, 2, 3, 0], +]; +const COLOR_SCALE = SH_C0 / 0.15; +const SH_SCALE1 = 1 << 3; +const SH_SCALE2 = 1 << 4; +function logTransform(value: number) { + return Math.sign(value) * Math.log(Math.abs(value) + 1); +} + +export class EszFile implements IFile { + private counts: number = 0; + private shDegree: number = 0; + /** + * @internal + */ + version: number; + /** + * @internal + */ + meta: Metadata; + /** + * @internal + */ + refs: Record = {}; + + private cached: Array<{ width: number; height: number; data: Uint8Array }>; + + async load(stream: ReadableStream, contentLength: number) { + const buffer = new Uint8Array(contentLength); + const reader = stream.getReader(); + let offset = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer.set(value!, offset); + offset += value!.length; + } + + this.refs = extractFromRootDir(unzipSync(buffer)); + const metaBuffer = this.refs['meta.json']; + if (!metaBuffer) { + throw new Error('SOG meta.json not found in the zip archive.'); + } + + const meta = (this.meta = JSON.parse(new TextDecoder().decode(metaBuffer)) as Metadata); + this.version = meta.version; + this.counts = meta.counts; + this.shDegree = meta.shDegree; + } + + private async loadTexture(path: string) { + let buffer: Uint8Array | undefined = this.refs[path]; + if (!buffer) { + if (isUrl(path)) { + buffer = await fetch(path) + .then(res => res.arrayBuffer()) + .then(buf => new Uint8Array(buf)); + } + } + if (!buffer) { + throw new Error(`Cannot load texture: ${path}`); + } + + const { data, width, height } = decodeWebP(buffer); + return { + data: new Uint8Array(data), + width, + height, + }; + } + + async read(stream: ReadableStream, contentLength: number, data: SplatData) { + await this.load(stream, contentLength); + + const offset = await data.initBlock(this.counts, this.shDegree); + const { resources } = this.meta; + this.cached = await Promise.all( + [resources.means_l, resources.means_u, resources.scales, resources.quats, resources.sh0, resources.shN] + .filter(path => !!path) + .map(path => this.loadTexture(path!)), + ); + + const setFn = data.set.bind(data) as SplatData['set']; + const setShFn = data.setShN.bind(data) as SplatData['setShN']; + const SCALE_LUT = new Float32Array(256); + for (let i = 0; i < 256; i++) { + SCALE_LUT[i] = Math.exp(i / 16 - 10); + } + const COLOR_LUT = new Float32Array(256); + for (let i = 0; i < 256; i++) { + COLOR_LUT[i] = (i / 255 - 0.5) * COLOR_SCALE + 0.5; + } + + const { + meta: { box }, + counts, + shDegree, + cached, + } = this; + const [means_l, means_u, scales, quats, color, shN] = cached.map(v => v.data); + + const { + min: [centerMinX, centerMinY, centerMinZ], + max: [centerMaxX, centerMaxY, centerMaxZ], + } = box; + const rangeX = (centerMaxX - centerMinX) / 65535; + const rangeY = (centerMaxY - centerMinY) / 65535; + const rangeZ = (centerMaxZ - centerMinZ) / 65535; + + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: [], + }; + for (let i = 0; i < counts; i++) { + const i4 = i * 4; + + const x = centerMinX + rangeX * (means_l[i4 + 0] + (means_u[i4 + 0] << 8)); + const y = centerMinY + rangeY * (means_l[i4 + 1] + (means_u[i4 + 1] << 8)); + const z = centerMinZ + rangeZ * (means_l[i4 + 2] + (means_u[i4 + 2] << 8)); + single.x = Math.sign(x) * (Math.exp(Math.abs(x)) - 1); + single.y = Math.sign(y) * (Math.exp(Math.abs(y)) - 1); + single.z = Math.sign(z) * (Math.exp(Math.abs(z)) - 1); + + single.sx = SCALE_LUT[scales[i4 + 0]]; + single.sy = SCALE_LUT[scales[i4 + 1]]; + single.sz = SCALE_LUT[scales[i4 + 2]]; + + TEMP_ROT[0] = (quats[i4 + 0] / 255 - 0.5) * Math.SQRT2; + TEMP_ROT[1] = (quats[i4 + 1] / 255 - 0.5) * Math.SQRT2; + TEMP_ROT[2] = (quats[i4 + 2] / 255 - 0.5) * Math.SQRT2; + TEMP_ROT[3] = Math.sqrt( + Math.max(0, 1.0 - TEMP_ROT[0] * TEMP_ROT[0] - TEMP_ROT[1] * TEMP_ROT[1] - TEMP_ROT[2] * TEMP_ROT[2]), + ); + const PERM = PERM_TABLE[quats[i4 + 3] - 252]; + single.qx = TEMP_ROT[PERM[0]]; + single.qy = TEMP_ROT[PERM[1]]; + single.qz = TEMP_ROT[PERM[2]]; + single.qw = TEMP_ROT[PERM[3]]; + + single.r = COLOR_LUT[color[i4 + 0]]; + single.g = COLOR_LUT[color[i4 + 1]]; + single.b = COLOR_LUT[color[i4 + 2]]; + single.a = color[i4 + 3] / 255; + + setFn(offset + i, single); + } + + if (shN) { + const shCounts = SH_MAPS[shDegree]; + const shCoeffs = shCounts / 3; + const sh = new Array(shCounts); + for (let i = 0; i < counts; i++) { + const o = i * shCounts; + for (let j = 0; j < shCoeffs; j++) { + sh[o + j * 3 + 0] = (shN[(i * shCoeffs + j) * 4 + 0] - 128) / 128; + sh[o + j * 3 + 1] = (shN[(i * shCoeffs + j) * 4 + 1] - 128) / 128; + sh[o + j * 3 + 2] = (shN[(i * shCoeffs + j) * 4 + 2] - 128) / 128; + } + setShFn(offset + i, sh); + } + } + + data.finishBlock(); + } + + async write(stream: WritableStream, data: SplatData, indices: Uint32Array = mortonSort(data)) { + const { counts, shDegree, shCounts, table } = data; + const width = Math.ceil(Math.sqrt(counts) / 4) * 4; + const height = Math.ceil(counts / width / 4) * 4; + const webPProfile = new WebPLosslessProfile(); + const output: Zippable = {}; + const meta: Metadata = { + version: 1, + counts, + shDegree, + box: { + min: [Infinity, Infinity, Infinity], + max: [-Infinity, -Infinity, -Infinity], + }, + resources: { + means_l: 'means_l.webp', + means_u: 'means_u.webp', + scales: 'scales.webp', + quats: 'quats.webp', + sh0: 'sh0.webp', + }, + }; + + const xCol = table[ColIdx.x]; + const yCol = table[ColIdx.y]; + const zCol = table[ColIdx.z]; + + // calculate minmax & transform + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + for (let i = 0; i < counts; i++) { + const idx = indices[i]; + const x = xCol[idx]; + const y = yCol[idx]; + const z = zCol[idx]; + if (x < minX) { + minX = x; + } + if (x > maxX) { + maxX = x; + } + if (y < minY) { + minY = y; + } + if (y > maxY) { + maxY = y; + } + if (z < minZ) { + minZ = z; + } + if (z > maxZ) { + maxZ = z; + } + } + meta.box.min = [minX, minY, minZ]; + meta.box.max = [maxX, maxY, maxZ]; + + { + logger.time('ESZ encoding means'); + minX = logTransform(minX); + minY = logTransform(minY); + minZ = logTransform(minZ); + maxX = logTransform(maxX); + maxY = logTransform(maxY); + maxZ = logTransform(maxZ); + const scaleX = 65535 / Math.max(maxX - minX, 1e-9); + const scaleY = 65535 / Math.max(maxY - minY, 1e-9); + const scaleZ = 65535 / Math.max(maxZ - minZ, 1e-9); + + const meansL = new Uint8Array(width * height * 4).fill(0xff); + const meansU = new Uint8Array(width * height * 4).fill(0xff); + for (let i = 0; i < indices.length; i++) { + const idx = indices[i]; + const x = (logTransform(xCol[idx]) - minX) * scaleX; + const y = (logTransform(yCol[idx]) - minY) * scaleY; + const z = (logTransform(zCol[idx]) - minZ) * scaleZ; + meansL[i * 4 + 0] = x & 0xff; + meansL[i * 4 + 1] = y & 0xff; + meansL[i * 4 + 2] = z & 0xff; + meansU[i * 4 + 0] = (x >> 8) & 0xff; + meansU[i * 4 + 1] = (y >> 8) & 0xff; + meansU[i * 4 + 2] = (z >> 8) & 0xff; + } + output['means_l.webp'] = encodeWebP(meansL, width, height, webPProfile); + output['means_u.webp'] = encodeWebP(meansU, width, height, webPProfile); + logger.timeEnd('ESZ encoding means'); + } + + { + logger.time('ESZ encoding scales'); + const sxCol = table[ColIdx.sx]; + const syCol = table[ColIdx.sy]; + const szCol = table[ColIdx.sz]; + const scales = new Uint8Array(width * height * 4).fill(0xff); + for (let i = 0; i < counts; i++) { + const idx = indices[i]; + scales[i * 4 + 0] = clamp(Math.round((Math.log(sxCol[idx]) + 10) * 16), 0, 255); + scales[i * 4 + 1] = clamp(Math.round((Math.log(syCol[idx]) + 10) * 16), 0, 255); + scales[i * 4 + 2] = clamp(Math.round((Math.log(szCol[idx]) + 10) * 16), 0, 255); + } + output['scales.webp'] = encodeWebP(scales, width, height, webPProfile); + logger.timeEnd('ESZ encoding scales'); + } + + { + logger.time('ESZ encoding quats'); + const qxCol = table[ColIdx.qx]; + const qyCol = table[ColIdx.qy]; + const qzCol = table[ColIdx.qz]; + const qwCol = table[ColIdx.qw]; + const quats = new Uint8Array(width * height * 4); + for (let i = 0; i < counts; i++) { + const idx = indices[i]; + TEMP_ROT[0] = qwCol[idx]; + TEMP_ROT[1] = qxCol[idx]; + TEMP_ROT[2] = qyCol[idx]; + TEMP_ROT[3] = qzCol[idx]; + const l = Math.sqrt( + TEMP_ROT[0] * TEMP_ROT[0] + + TEMP_ROT[1] * TEMP_ROT[1] + + TEMP_ROT[2] * TEMP_ROT[2] + + TEMP_ROT[3] * TEMP_ROT[3], + ); + TEMP_ROT.forEach((v, j) => { + TEMP_ROT[j] = v / l; + }); + const maxComp = TEMP_ROT.reduce( + (v, _, i) => (Math.abs(TEMP_ROT[i]) > Math.abs(TEMP_ROT[v]) ? i : v), + 0, + ); + if (TEMP_ROT[maxComp] < 0) { + TEMP_ROT.forEach((_, j) => { + TEMP_ROT[j] *= -1; + }); + } + TEMP_ROT.forEach((_, j) => (TEMP_ROT[j] *= Math.SQRT2)); + + const PERM = [ + [1, 2, 3], + [0, 2, 3], + [0, 1, 3], + [0, 1, 2], + ][maxComp]; + quats[i * 4] = (TEMP_ROT[PERM[0]] * 0.5 + 0.5) * 255; + quats[i * 4 + 1] = (TEMP_ROT[PERM[1]] * 0.5 + 0.5) * 255; + quats[i * 4 + 2] = (TEMP_ROT[PERM[2]] * 0.5 + 0.5) * 255; + quats[i * 4 + 3] = 252 + maxComp; + } + output['quats.webp'] = encodeWebP(quats, width, height, webPProfile); + logger.timeEnd('ESZ encoding quats'); + } + + { + logger.time('ESZ encoding sh0'); + const rCol = table[ColIdx.r]; + const gCol = table[ColIdx.g]; + const bCol = table[ColIdx.b]; + const aCol = table[ColIdx.a]; + const sh0 = new Uint8Array(width * height * 4).fill(0xff); + for (let i = 0; i < counts; i++) { + const idx = indices[i]; + sh0[i * 4 + 0] = clamp(Math.round(((rCol[idx] - 0.5) / COLOR_SCALE + 0.5) * 255), 0, 255); + sh0[i * 4 + 1] = clamp(Math.round(((gCol[idx] - 0.5) / COLOR_SCALE + 0.5) * 255), 0, 255); + sh0[i * 4 + 2] = clamp(Math.round(((bCol[idx] - 0.5) / COLOR_SCALE + 0.5) * 255), 0, 255); + sh0[i * 4 + 3] = clamp(Math.round(aCol[idx] * 255), 0, 255); + } + output['sh0.webp'] = encodeWebP(sh0, width, height, webPProfile); + logger.timeEnd('ESZ encoding sh0'); + } + + if (shDegree > 0) { + logger.time('ESZ encoding shN'); + const shCoeffs = shCounts / 3; + const pixels = counts * shCoeffs; + const shNWidth = Math.ceil(Math.sqrt(pixels) / 4) * 4; + const shNHeight = Math.ceil(pixels / shNWidth / 4) * 4; + const shN = new Uint8Array(shNWidth * shNHeight * 4).fill(0xff); + for (let i = 0; i < counts; i++) { + const idx = indices[i]; + const o = i * shCoeffs; + for (let j = 0; j < shCoeffs; j++) { + const scale = j < 3 ? SH_SCALE1 : SH_SCALE2; + shN[(o + j) * 4 + 0] = clamp( + Math.floor( + (Math.round(table[ColIdx.shOffset + (j * 3 + 0)][idx] * 128) + 128 + scale / 2) / scale, + ) * scale, + 0, + 255, + ); + shN[(o + j) * 4 + 1] = clamp( + Math.floor( + (Math.round(table[ColIdx.shOffset + (j * 3 + 1)][idx] * 128) + 128 + scale / 2) / scale, + ) * scale, + 0, + 255, + ); + shN[(o + j) * 4 + 2] = clamp( + Math.floor( + (Math.round(table[ColIdx.shOffset + (j * 3 + 2)][idx] * 128) + 128 + scale / 2) / scale, + ) * scale, + 0, + 255, + ); + } + } + output['shN.webp'] = encodeWebP(shN, shNWidth, shNHeight, webPProfile); + meta.resources.shN = 'shN.webp'; + logger.timeEnd('ESZ encoding shN'); + } + + output['meta.json'] = Buffer.from(JSON.stringify(meta), 'utf-8'); + const result = zipSync(output); + await stream.getWriter().write(result); + } +} diff --git a/packages/splat-transform/src/file/index.ts b/packages/splat-transform/src/file/index.ts new file mode 100644 index 0000000..2f575aa --- /dev/null +++ b/packages/splat-transform/src/file/index.ts @@ -0,0 +1,8 @@ +export type { IFile } from './IFile.js'; +export { KsplatFile } from './ksplat.js'; +export { PlyFile } from './ply.js'; +export { SogFile } from './sog.js'; +export { SplatFile } from './splat.js'; +export { SpzFile } from './spz.js'; +export { LccFile } from './lcc.js'; +export { EszFile } from './esz.js'; diff --git a/packages/splat-transform/src/file/ksplat.ts b/packages/splat-transform/src/file/ksplat.ts new file mode 100644 index 0000000..294da69 --- /dev/null +++ b/packages/splat-transform/src/file/ksplat.ts @@ -0,0 +1,385 @@ +import type { ISingleSplat, SplatData } from '../SplatData.js'; +import { SH_MAPS } from '../constant.js'; +import { fromHalf } from '../utils/index.js'; +import type { IFile } from './IFile.js'; + +interface KSplatCompression { + bytesPerCenter: number; + bytesPerScale: number; + bytesPerRotation: number; + bytesPerColor: number; + bytesPerSphericalHarmonicsComponent: number; + scaleOffsetBytes: number; + rotationOffsetBytes: number; + colorOffsetBytes: number; + sphericalHarmonicsOffsetBytes: number; + scaleRange: number; +} + +const KSPLAT_COMPRESSION: Record = { + 0: { + bytesPerCenter: 12, + bytesPerScale: 12, + bytesPerRotation: 16, + bytesPerColor: 4, + bytesPerSphericalHarmonicsComponent: 4, + scaleOffsetBytes: 12, + rotationOffsetBytes: 24, + colorOffsetBytes: 40, + sphericalHarmonicsOffsetBytes: 44, + scaleRange: 1, + }, + 1: { + bytesPerCenter: 6, + bytesPerScale: 6, + bytesPerRotation: 8, + bytesPerColor: 4, + bytesPerSphericalHarmonicsComponent: 2, + scaleOffsetBytes: 6, + rotationOffsetBytes: 12, + colorOffsetBytes: 20, + sphericalHarmonicsOffsetBytes: 24, + scaleRange: 32767, + }, + 2: { + bytesPerCenter: 6, + bytesPerScale: 6, + bytesPerRotation: 8, + bytesPerColor: 4, + bytesPerSphericalHarmonicsComponent: 1, + scaleOffsetBytes: 6, + rotationOffsetBytes: 12, + colorOffsetBytes: 20, + sphericalHarmonicsOffsetBytes: 24, + scaleRange: 32767, + }, +}; + +interface KSplatHeader { + versionMajor: number; + versionMinor: number; + maxSectionCount: number; + sectionCount: number; + maxSplatCount: number; + splatCount: number; + compressionLevel: number; + sceneCenter: [number, number, number]; + shRange: [number, number]; +} + +interface KSplatSection { + sectionSplatCount: number; + sectionMaxSplatCount: number; + bucketSize: number; + bucketCount: number; + bucketBlockSize: number; + bucketStorageSizeBytes: number; + compressionScaleRange: number; + fullBucketCount: number; + partiallyFilledBucketCount: number; + shDegree: number; +} + +const SHIndex = [ + 0, + 3, + 6, + 1, + 4, + 7, + 2, + 5, + 8, // sh1 + 9, + 14, + 19, + 10, + 15, + 20, + 11, + 16, + 21, + 12, + 17, + 22, + 13, + 18, + 23, // sh2 + 24, + 31, + 38, + 25, + 32, + 39, + 26, + 33, + 40, + 27, + 34, + 41, + 28, + 35, + 42, + 29, + 36, + 43, + 30, + 37, + 44, // sh3 +]; + +const HEADER_BYTES = 4096; +const SECTION_BYTES = 1024; + +export class KsplatFile implements IFile { + private counts: number = 0; + private shDegree: number = 0; + private header: KSplatHeader; + private sections: KSplatSection[]; + + private buffer: Uint8Array; + + private load(buffer: Uint8Array) { + this.buffer = buffer; + + const header = new DataView(buffer.buffer, 0, HEADER_BYTES); + const versionMajor = header.getUint8(0); + const versionMinor = header.getUint8(1); + if (versionMajor !== 0 || versionMinor < 1) { + throw new Error(`Unsupported .ksplat version: ${versionMajor}.${versionMinor}`); + } + const maxSectionCount = header.getUint32(4, true); + const sectionCount = header.getUint32(8, true); + const maxSplatCount = header.getUint32(12, true); + const splatCount = header.getUint32(16, true); + const compressionLevel = header.getUint16(20, true); + if (compressionLevel < 0 || compressionLevel > 2) { + throw new Error(`Invalid .ksplat compression level: ${compressionLevel}`); + } + const sceneCenterX = header.getFloat32(24, true); + const sceneCenterY = header.getFloat32(28, true); + const sceneCenterZ = header.getFloat32(32, true); + const minSH = header.getFloat32(36, true) || -1.5; + const maxSH = header.getFloat32(40, true) || 1.5; + + let maxSHDegree: number = 0; + const sections: KSplatSection[] = []; + for (let i = 0; i < maxSectionCount; i++) { + const section = new DataView(buffer.buffer, HEADER_BYTES + i * SECTION_BYTES, SECTION_BYTES); + const sectionSplatCount = section.getUint32(0, true); + const sectionMaxSplatCount = section.getUint32(4, true); + const bucketSize = section.getUint32(8, true); + const bucketCount = section.getUint32(12, true); + const bucketBlockSize = section.getFloat32(16, true); + const bucketStorageSizeBytes = section.getUint16(20, true); + const compressionScaleRange = section.getUint32(24, true); + const fullBucketCount = section.getUint32(32, true); + const partiallyFilledBucketCount = section.getUint32(36, true); + const shDegree = section.getUint16(40, true); + + maxSHDegree = Math.max(maxSHDegree, shDegree); + sections.push({ + sectionSplatCount, + sectionMaxSplatCount, + bucketSize, + bucketCount, + bucketBlockSize, + bucketStorageSizeBytes, + compressionScaleRange: compressionScaleRange || KSPLAT_COMPRESSION[compressionLevel].scaleRange, + fullBucketCount, + partiallyFilledBucketCount, + shDegree, + }); + } + + this.header = { + versionMajor, + versionMinor, + maxSectionCount, + sectionCount, + maxSplatCount, + splatCount, + compressionLevel, + sceneCenter: [sceneCenterX, sceneCenterY, sceneCenterZ], + shRange: [minSH, maxSH], + }; + this.sections = sections; + this.counts = splatCount; + this.shDegree = maxSHDegree; + } + + async read(stream: ReadableStream, contentLength: number, data: SplatData) { + let BlockOffset: number = 0; + { + const buffer = new Uint8Array(contentLength); + const reader = stream.getReader(); + let offset = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer.set(value!, offset); + offset += value!.length; + } + this.load(buffer); + BlockOffset = await data.initBlock(this.counts, this.shDegree); + } + + const setFn = data.set.bind(data) as SplatData['set']; + const setShFn = data.setShN.bind(data) as SplatData['setShN']; + + const { buffer, header, sections, shDegree: maxSHDegree } = this; + const { + maxSectionCount, + compressionLevel, + shRange: [minSH, maxSH], + } = header; + const isHighQualitySplatData = compressionLevel === 0; + + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: [], + }; + const maxSHSize = SH_MAPS[maxSHDegree]; + const shData = new Array(maxSHSize); + + let sectionBase = HEADER_BYTES + maxSectionCount * SECTION_BYTES; + for (let i = 0; i < maxSectionCount; i++) { + const { + sectionSplatCount, + sectionMaxSplatCount, + bucketSize, + bucketCount, + bucketBlockSize, + bucketStorageSizeBytes, + fullBucketCount, + partiallyFilledBucketCount, + compressionScaleRange, + shDegree, + } = sections[i]; + + const fullBucketSplats = fullBucketCount * bucketSize; + const bucketsMetaDataSizeBytes = partiallyFilledBucketCount * 4; + const bucketsStorageSizeBytes = bucketStorageSizeBytes * bucketCount + bucketsMetaDataSizeBytes; + const shComponents = SH_MAPS[shDegree]; + const { + bytesPerCenter, + bytesPerScale, + bytesPerRotation, + bytesPerColor, + bytesPerSphericalHarmonicsComponent, + scaleOffsetBytes, + rotationOffsetBytes, + colorOffsetBytes, + sphericalHarmonicsOffsetBytes, + } = KSPLAT_COMPRESSION[compressionLevel]; + const bytesPerSplat = + bytesPerCenter + + bytesPerScale + + bytesPerRotation + + bytesPerColor + + shComponents * bytesPerSphericalHarmonicsComponent; + const splatDataStorageSizeBytes = bytesPerSplat * sectionMaxSplatCount; + const storageSizeBytes = splatDataStorageSizeBytes + bucketsStorageSizeBytes; + + const compressionScaleFactor = bucketBlockSize / 2 / compressionScaleRange; + const bucketsBase = sectionBase + bucketsMetaDataSizeBytes; + const dataBase = sectionBase + bucketsStorageSizeBytes; + const data = new DataView(buffer.buffer, dataBase, splatDataStorageSizeBytes); + const bucketArray = new Float32Array(buffer.buffer, bucketsBase, bucketCount * 3); + const partiallyFilledBucketLengths = new Uint32Array( + buffer.buffer, + sectionBase, + partiallyFilledBucketCount, + ); + + let partialBucketIndex = fullBucketCount; + let partialBucketBase = fullBucketSplats; + for (let j = 0; j < sectionSplatCount; j++) { + const splatOffset = j * bytesPerSplat; + + let bucketIndex: number; + if (j < fullBucketSplats) { + bucketIndex = Math.floor(j / bucketSize); + } else { + const bucketLength = partiallyFilledBucketLengths[partialBucketIndex - fullBucketCount]; + if (j >= partialBucketBase + bucketLength) { + partialBucketIndex += 1; + partialBucketBase += bucketLength; + } + bucketIndex = partialBucketIndex; + } + + if (isHighQualitySplatData) { + single.x = data.getFloat32(splatOffset + 0, true); + single.y = data.getFloat32(splatOffset + 4, true); + single.z = data.getFloat32(splatOffset + 8, true); + single.sx = data.getFloat32(splatOffset + scaleOffsetBytes + 0, true); + single.sy = data.getFloat32(splatOffset + scaleOffsetBytes + 4, true); + single.sz = data.getFloat32(splatOffset + scaleOffsetBytes + 8, true); + single.qw = data.getFloat32(splatOffset + rotationOffsetBytes + 0, true); + single.qx = data.getFloat32(splatOffset + rotationOffsetBytes + 4, true); + single.qy = data.getFloat32(splatOffset + rotationOffsetBytes + 8, true); + single.qz = data.getFloat32(splatOffset + rotationOffsetBytes + 12, true); + } else { + single.x = + (data.getUint16(splatOffset + 0, true) - compressionScaleRange) * compressionScaleFactor + + bucketArray[3 * bucketIndex + 0]; + single.y = + (data.getUint16(splatOffset + 2, true) - compressionScaleRange) * compressionScaleFactor + + bucketArray[3 * bucketIndex + 1]; + single.z = + (data.getUint16(splatOffset + 4, true) - compressionScaleRange) * compressionScaleFactor + + bucketArray[3 * bucketIndex + 2]; + single.sx = fromHalf(data.getUint16(splatOffset + scaleOffsetBytes + 0, true)); + single.sy = fromHalf(data.getUint16(splatOffset + scaleOffsetBytes + 2, true)); + single.sz = fromHalf(data.getUint16(splatOffset + scaleOffsetBytes + 4, true)); + single.qw = fromHalf(data.getUint16(splatOffset + rotationOffsetBytes + 0, true)); + single.qx = fromHalf(data.getUint16(splatOffset + rotationOffsetBytes + 2, true)); + single.qy = fromHalf(data.getUint16(splatOffset + rotationOffsetBytes + 4, true)); + single.qz = fromHalf(data.getUint16(splatOffset + rotationOffsetBytes + 6, true)); + } + single.r = data.getUint8(splatOffset + colorOffsetBytes + 0) / 255; + single.g = data.getUint8(splatOffset + colorOffsetBytes + 1) / 255; + single.b = data.getUint8(splatOffset + colorOffsetBytes + 2) / 255; + single.a = data.getUint8(splatOffset + colorOffsetBytes + 3) / 255; + + setFn(j + BlockOffset, single); + + const shOffsetBytes = splatOffset + sphericalHarmonicsOffsetBytes; + for (let k = 0; k < shComponents; k++) { + shData[k] = + compressionLevel === 0 + ? data.getFloat32(shOffsetBytes + SHIndex[k] * 4, true) + : compressionLevel === 1 + ? fromHalf(data.getUint16(shOffsetBytes + SHIndex[k] * 2, true)) + : minSH + (data.getUint8(shOffsetBytes + SHIndex[k]) / 255) * (maxSH - minSH); + } + for (let k = maxSHSize - 1; k >= shComponents; k--) { + shData[k] = 0; + } + setShFn(j + BlockOffset, shData); + } + sectionBase += storageSizeBytes; + } + data.finishBlock(); + } + + async write(_stream: WritableStream, _data: SplatData) { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/splat-transform/src/file/lcc.ts b/packages/splat-transform/src/file/lcc.ts new file mode 100644 index 0000000..9a4336f --- /dev/null +++ b/packages/splat-transform/src/file/lcc.ts @@ -0,0 +1,214 @@ +import { unzipSync } from 'fflate'; +import type { ISingleSplat, SplatData } from '../SplatData.js'; +import { extractFromRootDir } from '../utils/index.js'; +import type { IFile } from './IFile.js'; + +interface MetaAttribute { + name: string; + min: number[]; + max: number[]; +} + +interface Metadata { + totalLevel: number; + fileType: string; + splats: number[]; + attributes: MetaAttribute[]; +} + +interface LodInfo { + points: number; + offset: number; + size: number; +} + +interface BlockInfo { + x: number; + y: number; + lods: LodInfo[]; +} + +const ZIP_MAGIC = 0x04034b50; + +const SQRT_2 = 1.414213562373095; +const SQRT_2_INV = 0.7071067811865475; +function decodeRotation(v: number) { + const d0 = (v & 1023) / 1023.0; + const d1 = ((v >> 10) & 1023) / 1023.0; + const d2 = ((v >> 20) & 1023) / 1023.0; + const d3 = (v >> 30) & 3; + + const qx = d0 * SQRT_2 - SQRT_2_INV; + const qy = d1 * SQRT_2 - SQRT_2_INV; + const qz = d2 * SQRT_2 - SQRT_2_INV; + let sum = qx * qx + qy * qy + qz * qz; + sum = Math.min(1.0, sum); + const qw = Math.sqrt(1 - sum); + + if (d3 === 0) { + return [qw, qx, qy, qz]; + } else if (d3 === 1) { + return [qx, qw, qy, qz]; + } else if (d3 === 2) { + return [qx, qy, qw, qz]; + } + + return [qx, qy, qz, qw]; +} + +function DecodePacked_11_10_11(enc: number) { + return [(enc & 0x7ff) / 2047.0, ((enc >> 11) & 0x3ff) / 1023.0, ((enc >> 21) & 0x7ff) / 2047.0]; +} + +function mix(min: number, max: number, s: number) { + return (1.0 - s) * min + s * max; +} + +export class LccFile implements IFile { + private counts: number = 0; + private shDegree: number = 0; + private meta: Metadata; + private refs: Record = {}; + + private load(buffer: Uint8Array) { + const view = new DataView(buffer.buffer); + if (view.getUint32(0, true) !== ZIP_MAGIC) { + throw new Error('LCC file is not a valid zip archive.'); + } + this.refs = extractFromRootDir(unzipSync(buffer)); + + if (!['meta.lcc', 'index.bin', 'data.bin'].every(name => !!this.refs[name])) { + throw new Error('LCC file is missing required files.'); + } + this.meta = JSON.parse(new TextDecoder().decode(this.refs['meta.lcc'])); + this.counts = this.meta.splats[0]; + this.shDegree = !!this.refs['shcoef.bin'] ? 3 : 0; + } + + async read(stream: ReadableStream, contentLength: number, data: SplatData) { + let BlockOffset: number = 0; + { + const buffer = new Uint8Array(contentLength); + const reader = stream.getReader(); + let offset = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer.set(value!, offset); + offset += value!.length; + } + this.load(buffer); + BlockOffset = await data.initBlock(this.counts, this.shDegree); + } + + const setFn = data.set.bind(data) as SplatData['set']; + const setShFn = data.setShN.bind(data) as SplatData['setShN']; + + const { meta, refs } = this; + + const infos: BlockInfo[] = []; + { + const index = new DataView(refs['index.bin'].buffer); + const infoCounts = Math.floor(index.byteLength / (4 + 16 * meta.totalLevel)); + let offset = 0; + for (let i = 0; i < infoCounts; i++) { + const x = index.getInt16(offset, true); + offset += 2; + const y = index.getInt16(offset, true); + offset += 2; + + const lods: LodInfo[] = []; + for (let j = 0; j < meta.totalLevel; j++) { + const points = index.getInt32(offset, true); + offset += 4; + const ldOffset = Number(index.getBigInt64(offset, true)); + offset += 8; + const size = index.getInt32(offset, true); + offset += 4; + lods.push({ points, offset: ldOffset, size }); + } + + infos.push({ x, y, lods }); + } + } + + const attributes = meta.attributes.reduce>((p, c) => { + p[c.name] = c; + return p; + }, {}); + const { + scale: { min: scaleMin, max: scaleMax }, + shcoef: { min: shMin, max: shMax }, + } = attributes; + + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: [], + }; + const shData = new Array(45); + let index = BlockOffset; + for (let i = 0; i < infos.length; i++) { + const info = infos[i]; + const { points, offset, size } = info.lods[0]; + const dataview = new DataView(refs['data.bin'].buffer, offset, size); + const shN = refs['shcoef.bin'] ? new DataView(refs['shcoef.bin'].buffer, offset * 2, size * 2) : undefined; + for (let j = 0; j < points; j++) { + const off = j * 32; + + single.x = dataview.getFloat32(off + 0, true); + single.y = dataview.getFloat32(off + 4, true); + single.z = dataview.getFloat32(off + 8, true); + + single.r = dataview.getUint8(off + 12) / 255.0; + single.g = dataview.getUint8(off + 13) / 255.0; + single.b = dataview.getUint8(off + 14) / 255.0; + single.a = dataview.getUint8(off + 15) / 255.0; + + single.sx = mix(scaleMin[0], scaleMax[0], dataview.getUint16(off + 16, true) / 65535.0); + single.sy = mix(scaleMin[1], scaleMax[1], dataview.getUint16(off + 18, true) / 65535.0); + single.sz = mix(scaleMin[2], scaleMax[2], dataview.getUint16(off + 20, true) / 65535.0); + + const quat = decodeRotation(dataview.getUint32(off + 22, true)); + single.qx = quat[0]; + single.qy = quat[1]; + single.qz = quat[2]; + single.qw = quat[3]; + + setFn(index, single); + + if (shN) { + const shOff = off * 2; + for (let k = 0; k < 15; k++) { + const v = DecodePacked_11_10_11(shN.getUint32(shOff + k * 4, true)); + shData[k * 3] = mix(shMin[0], shMax[0], v[0]); + shData[k * 3 + 1] = mix(shMin[1], shMax[1], v[1]); + shData[k * 3 + 2] = mix(shMin[2], shMax[2], v[2]); + } + setShFn(index, shData); + } + + index++; + } + } + data.finishBlock(); + } + + async write(_stream: WritableStream, _data: SplatData) { + throw new Error('Method not implemented.'); + } +} diff --git a/packages/splat-transform/src/file/ply.ts b/packages/splat-transform/src/file/ply.ts new file mode 100644 index 0000000..5759493 --- /dev/null +++ b/packages/splat-transform/src/file/ply.ts @@ -0,0 +1,595 @@ +import type { ISingleSplat, SplatData } from '../SplatData.js'; +import { SH_C0, SH_MAPS, NUM_F_REST_TO_SH_DEGREE } from '../constant.js'; +import { BufferReader, StreamChunkDecoder, type ChunkDecoder, mortonSort } from '../utils/index.js'; +import type { IFile } from './IFile.js'; + +type PlyPropertyType = 'char' | 'uchar' | 'short' | 'ushort' | 'int' | 'uint' | 'float' | 'double'; + +interface PlyElement { + name: string; + count: number; + properties: Record; +} + +const F_REST_REGEX = /^f_rest_([0-9]{1,2})$/; +function createEmptyBlock( + properties: Record, + shDegree: number, +): Record { + const result: Record = { + f_rest: new Array(SH_MAPS[shDegree]), + }; + for (const name of Object.keys(properties)) { + if (F_REST_REGEX.test(name)) { + continue; + } + result[name] = 0; + } + return result; +} + +const FIELD_BYTES: Record = { + char: 1, + uchar: 1, + short: 2, + ushort: 2, + int: 4, + uint: 4, + float: 4, + double: 8, +}; + +function createParseFn( + properties: Record, + littleEndian: boolean, + shDegree: number, +): [number, (data: DataView, offset: number, item: Record) => void] { + function createPropertyParse(type: PlyPropertyType) { + switch (type) { + case 'char': + return 'data.getInt8(offset)'; + case 'uchar': + return 'data.getUint8(offset)'; + case 'short': + return `data.getInt16(offset, ${littleEndian})`; + case 'ushort': + return `data.getUint16(offset, ${littleEndian})`; + case 'int': + return `data.getInt32(offset, ${littleEndian})`; + case 'uint': + return `data.getUint32(offset, ${littleEndian})`; + case 'float': + return `data.getFloat32(offset, ${littleEndian})`; + case 'double': + return `data.getFloat64(offset, ${littleEndian})`; + } + } + + let itemSize = 0; + const parserSrc: string[] = []; + const shLen = SH_MAPS[shDegree] / 3; + for (const [propertyName, propertyType] of Object.entries(properties)) { + const fRestMatch = propertyName.match(F_REST_REGEX); + if (fRestMatch) { + let fRestIndex = parseInt(fRestMatch[1], 10); + fRestIndex = (fRestIndex % shLen) * 3 + Math.floor(fRestIndex / shLen); + parserSrc.push(`item.f_rest[${fRestIndex}] = ${createPropertyParse(propertyType)};`); + } else { + parserSrc.push(`item.${propertyName} = ${createPropertyParse(propertyType)};`); + } + parserSrc.push(`offset += ${FIELD_BYTES[propertyType]};`); + itemSize += FIELD_BYTES[propertyType]; + } + + return [itemSize, new Function('data', 'offset', 'item', parserSrc.join('\n')) as any]; +} + +interface ISSChunk { + min_x: number; + min_y: number; + min_z: number; + max_x: number; + max_y: number; + max_z: number; + min_scale_x: number; + min_scale_y: number; + min_scale_z: number; + max_scale_x: number; + max_scale_y: number; + max_scale_z: number; + min_r: number; + min_g: number; + min_b: number; + max_r: number; + max_g: number; + max_b: number; +} + +interface ISSVertexBlock { + packed_position: number; + packed_rotation: number; + packed_scale: number; + packed_color: number; +} + +interface IVertexBlock { + x: number; + y: number; + z: number; + scale_0: number; + scale_1: number; + scale_2: number; + rot_0: number; + rot_1: number; + rot_2: number; + rot_3: number; + f_dc_0: number; + f_dc_1: number; + f_dc_2: number; + opacity: number; + f_rest: number[]; +} + +const HeaderTerminator = 'end_header\n'; +export class PlyFile implements IFile { + private littleEndian = true; + private comments: string[] = []; + private elements: Record = {}; + private isSuperSplatCompressed: boolean = false; + + private counts: number = 0; + private shDegree: number = 0; + + private initHeader(header: string) { + let curElement: PlyElement | undefined; + const lines = header + .trim() + .split('\n') + .map(v => v.trim()) + .filter(v => !!v); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (i === 0) { + if (line !== 'ply') { + throw new Error('Invalid PLY header'); + } + continue; + } + + const fields = line.split(' '); + switch (fields[0]) { + case 'format': + if (fields[1] === 'binary_little_endian') { + this.littleEndian = true; + } else if (fields[1] === 'binary_big_endian') { + this.littleEndian = false; + } else { + throw new Error(`Unsupported PLY format: ${fields[1]}`); + } + if (fields[2] !== '1.0') { + throw new Error(`Unsupported PLY version: ${fields[2]}`); + } + break; + case 'comment': + this.comments.push(line.slice('comment '.length)); + break; + case 'element': { + const name = fields[1]; + curElement = this.elements[name] = { + name, + count: parseInt(fields[2], 10), + properties: {}, + }; + break; + } + case 'property': + if (!curElement) { + throw new Error('Property must be inside an element'); + } + if (!FIELD_BYTES[fields[1] as PlyPropertyType]) { + throw new Error(`Unsupported property type '${fields[1]}'`); + } + curElement.properties[fields[2]] = fields[1] as PlyPropertyType; + break; + case 'end_header': + break; + default: + console.warn(`Skipping unsupported PLY keyword: ${fields[0]}`); + break; + } + } + + const { elements } = this; + const isSuperSplatCompressed = (this.isSuperSplatCompressed = !!elements.chunk); + this.counts = elements.vertex?.count ?? 0; + + const shElement = isSuperSplatCompressed ? elements.sh : elements.vertex; + if (shElement) { + const { properties } = shElement; + let num_f_rest = 0; + while (properties[`f_rest_${num_f_rest}`]) { + num_f_rest += 1; + } + const shDegree = NUM_F_REST_TO_SH_DEGREE[num_f_rest]; + if (shDegree === undefined) { + throw new Error(`Unsupported number of SH coefficients: ${num_f_rest}`); + } + this.shDegree = shDegree; + } + + // check invalid + for (const name in elements) { + const { properties } = elements[name]; + + if (isSuperSplatCompressed) { + if (name === 'chunk') { + const { + 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, + } = properties; + if ( + !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 + ) { + throw new Error('Missing Compressed PLY chunk properties'); + } + } else if (name === 'vertex') { + const { packed_position, packed_rotation, packed_scale, packed_color } = properties; + if (!packed_position || !packed_rotation || !packed_scale || !packed_color) { + throw new Error('Missing Compressed PLY vertex properties'); + } + } + } else { + if (name === 'vertex') { + const { + 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, + } = properties; + if ( + !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 + ) { + throw new Error('Missing PLY vertex properties'); + } + } + } + } + } + + async read(stream: ReadableStream, _contentLength: number, data: SplatData) { + const setFn = data.set.bind(data) as SplatData['set']; + const setShFn = data.setShN.bind(data) as SplatData['setShN']; + + let headerParsed: boolean = false; + let header = ''; + + const reader = new BufferReader(); + const decoder = new StreamChunkDecoder(reader); + + let BlockOffset: number = 0; + const chunks: ISSChunk[] = []; + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: [], + }; + const initDecoder = () => { + const { elements, littleEndian, isSuperSplatCompressed, shDegree } = this; + const chunkDecoders: ChunkDecoder[] = []; + + for (const name in elements) { + const { count, properties } = elements[name]; + const block = createEmptyBlock(properties, shDegree); + const [itemSize, parseFn] = createParseFn(properties, littleEndian, shDegree); + + let fn: (index: number, item: any) => void = () => {}; + if (isSuperSplatCompressed) { + if (name === 'chunk') { + fn = (i: number, item: ISSChunk) => { + chunks[i - BlockOffset] = { ...item }; + }; + } else if (name === 'sh') { + fn = (i: number, item: Record) => { + setShFn( + i, + item.f_rest.map(v => (v * 8) / 255 - 4), + ); + }; + } else if (name === 'vertex') { + fn = (i: number, item: ISSVertexBlock) => { + const chunk = chunks[(i - BlockOffset) >>> 8]; + if (!chunk) { + throw new Error('Missing PLY chunk'); + } + const { + 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, + } = chunk; + const { packed_position, packed_rotation, packed_scale, packed_color } = item; + + single.x = (((packed_position >>> 21) & 2047) / 2047) * (max_x - min_x) + min_x; + single.y = (((packed_position >>> 11) & 1023) / 1023) * (max_y - min_y) + min_y; + single.z = ((packed_position & 2047) / 2047) * (max_z - min_z) + min_z; + + const r0 = (((packed_rotation >>> 20) & 1023) / 1023 - 0.5) * Math.SQRT2; + const r1 = (((packed_rotation >>> 10) & 1023) / 1023 - 0.5) * Math.SQRT2; + const r2 = ((packed_rotation & 1023) / 1023 - 0.5) * Math.SQRT2; + const rr = Math.sqrt(Math.max(0, 1.0 - r0 * r0 - r1 * r1 - r2 * r2)); + const rOrder = packed_rotation >>> 30; + single.qx = rOrder === 0 ? r0 : rOrder === 1 ? rr : r1; + single.qy = rOrder <= 1 ? r1 : rOrder === 2 ? rr : r2; + single.qz = rOrder <= 2 ? r2 : rr; + single.qw = rOrder === 0 ? rr : r0; + + single.sx = Math.exp( + (((packed_scale >>> 21) & 2047) / 2047) * (max_scale_x - min_scale_x) + min_scale_x, + ); + single.sy = Math.exp( + (((packed_scale >>> 11) & 1023) / 1023) * (max_scale_y - min_scale_y) + min_scale_y, + ); + single.sz = Math.exp( + ((packed_scale & 2047) / 2047) * (max_scale_z - min_scale_z) + min_scale_z, + ); + + single.r = (((packed_color >>> 24) & 255) / 255) * (max_r - min_r) + min_r; + single.g = (((packed_color >>> 16) & 255) / 255) * (max_g - min_g) + min_g; + single.b = (((packed_color >>> 8) & 255) / 255) * (max_b - min_b) + min_b; + single.a = (packed_color & 255) / 255; + + setFn(i, single); + }; + } + } else if (name === 'vertex') { + fn = (i: number, item: IVertexBlock) => { + single.x = item.x; + single.y = item.y; + single.z = item.z; + single.sx = Math.exp(item.scale_0); + single.sy = Math.exp(item.scale_1); + single.sz = Math.exp(item.scale_2); + single.qx = item.rot_1; + single.qy = item.rot_2; + single.qz = item.rot_3; + single.qw = item.rot_0; + single.r = item.f_dc_0 * SH_C0 + 0.5; + single.g = item.f_dc_1 * SH_C0 + 0.5; + single.b = item.f_dc_2 * SH_C0 + 0.5; + single.a = 1.0 / (1.0 + Math.exp(-item.opacity)); + setFn(i, single); + setShFn(i, item.f_rest); + }; + } + + chunkDecoders.push({ + init: () => [count, itemSize], + decode: (offset, counts, buffer) => { + offset += BlockOffset; + const dataview = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + for (let i = 0; i < counts; i++) { + parseFn(dataview, i * itemSize, block); + fn(offset + i, block); + } + }, + }); + } + + decoder.setDecoders(chunkDecoders); + }; + + const textDecoder = new TextDecoder(); + const source = stream.getReader(); + while (true) { + const { done, value } = await source.read(); + if (done) { + break; + } + reader.write(value!); + + if (!headerParsed) { + const HeaderReadBlockSize = 4096; + const counts = (reader.remaining / HeaderReadBlockSize) | 0; + for (let i = 0; i < counts; i++) { + const chunk = reader.read(HeaderReadBlockSize); + header += textDecoder.decode(chunk, { stream: true }); + const idx = header.indexOf(HeaderTerminator); + if (idx >= 0) { + header = header.slice(0, idx + HeaderTerminator.length); + reader.head -= + HeaderReadBlockSize - (new TextEncoder().encode(header).length % HeaderReadBlockSize); + this.initHeader(header); + initDecoder(); + BlockOffset = await data.initBlock(this.counts, this.shDegree); + headerParsed = true; + break; + } + } + if (!headerParsed) { + continue; + } + } + + decoder.flush(); + } + data.finishBlock(); + } + + async write(stream: WritableStream, data: SplatData, indices: Uint32Array = mortonSort(data)) { + const writer = stream.getWriter(); + + const counts = data.counts; + const shDegree = data.shDegree; + const shCounts = SH_MAPS[shDegree]; + const shCoeffs = shCounts / 3; + + const header = [ + 'ply', + 'format binary_little_endian 1.0', + `comment Generated by EGS`, + `element vertex ${counts}`, + 'property float x', + 'property float y', + 'property float z', + 'property float scale_0', + 'property float scale_1', + 'property float scale_2', + 'property float rot_1', + 'property float rot_2', + 'property float rot_3', + 'property float rot_0', + 'property float f_dc_0', + 'property float f_dc_1', + 'property float f_dc_2', + 'property float opacity', + new Array(shCounts).fill(0).map((_, i) => `property float f_rest_${i}`), + 'end_header', + '', + ] + .flat() + .join('\n'); + writer.write(new TextEncoder().encode(header)); + + const ItemSize = 14 + shCounts; + const chunkSize = 1024; + const chunkCounts = Math.ceil(counts / chunkSize); + + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: new Array(shCounts), + }; + const shN = single.shN; + for (let i = 0; i < chunkCounts; i++) { + if (writer.desiredSize! <= 0) { + await writer.ready; + } + + const currentChunkSize = Math.min(chunkSize, counts - i * chunkSize); + const chunk = new Float32Array(currentChunkSize * ItemSize); + const offset = i * chunkSize; + for (let j = 0; j < currentChunkSize; j++) { + const idx = indices[offset + j]; + data.get(idx, single); + data.getShN(idx, shN); + const o = j * ItemSize; + chunk[o + 0] = single.x; + chunk[o + 1] = single.y; + chunk[o + 2] = single.z; + chunk[o + 3] = Math.log(single.sx); + chunk[o + 4] = Math.log(single.sy); + chunk[o + 5] = Math.log(single.sz); + chunk[o + 6] = single.qx; + chunk[o + 7] = single.qy; + chunk[o + 8] = single.qz; + chunk[o + 9] = single.qw; + chunk[o + 10] = (single.r - 0.5) / SH_C0; + chunk[o + 11] = (single.g - 0.5) / SH_C0; + chunk[o + 12] = (single.b - 0.5) / SH_C0; + chunk[o + 13] = single.a === 0 ? -100 : -Math.log(1 / single.a - 1); + for (let k = 0; k < shCounts; k++) { + chunk[o + 14 + k] = shN[(k % shCoeffs) * 3 + ((k / shCoeffs) | 0)]; + } + } + + writer.write(new Uint8Array(chunk.buffer)); + await Promise.resolve(); + } + + await writer.close(); + } +} diff --git a/packages/splat-transform/src/file/sog.ts b/packages/splat-transform/src/file/sog.ts new file mode 100644 index 0000000..d872687 --- /dev/null +++ b/packages/splat-transform/src/file/sog.ts @@ -0,0 +1,694 @@ +import { unzipSync, zipSync, type Zippable } from 'fflate'; +import { Buffer } from 'node:buffer'; +import { decodeWebP, encodeWebP, WebPLosslessProfile } from '../native/index.js'; +import { type ISingleSplat, type SplatData, ColIdx } from '../SplatData.js'; +import { SH_C0, SH_MAPS, NUM_F_REST_TO_SH_DEGREE } from '../constant.js'; +import { + getOrCreateDevice, + kMeans, + logger, + mortonSort, + quantize1d, + isUrl, + extractFromRootDir, + clamp, +} from '../utils/index.js'; +import type { IFile } from './IFile.js'; + +export interface SogMetadataV1 { + version: undefined; + means: { + shape: number[]; + dtype: string; + mins: number[]; + maxs: number[]; + files: string[]; + }; + scales: { + shape: number[]; + dtype: string; + mins: number[]; + maxs: number[]; + files: string[]; + }; + quats: { shape: number[]; dtype: string; encoding?: string; files: string[] }; + sh0: { + shape: number[]; + dtype: string; + mins: number[]; + maxs: number[]; + files: string[]; + }; + shN?: { + shape: number[]; + dtype: string; + mins: number; + maxs: number; + quantization: number; + files: string[]; + }; +} + +export interface SogMetadataV2 { + version: number; + count: number; + means: { + mins: number[]; + maxs: number[]; + files: string[]; + }; + scales: { + codebook: number[]; + files: string[]; + }; + quats: { files: string[] }; + sh0: { + codebook: number[]; + files: string[]; + }; + shN?: { + count: number; + bands: number; + codebook: number[]; + files: string[]; + }; +} + +export type SogMetadata = SogMetadataV1 | SogMetadataV2; + +const ZIP_MAGIC = 0x04034b50; +const PERM_TABLE = [ + // original quat idx ---> actual storage idx + [0, 1, 2, 3], + [3, 1, 2, 0], + [1, 3, 2, 0], + [1, 2, 3, 0], +]; +const TEMP_ROT = new Float32Array(4); + +function logTransform(value: number) { + return Math.sign(value) * Math.log(Math.abs(value) + 1); +} + +function writeTableData(table: Uint8Array[], indices: Uint32Array, width: number, height: number, channels = 4) { + const data = new Uint8Array(width * height * channels); + const numColumns = table.length; + for (let i = 0; i < indices.length; ++i) { + const idx = indices[i]; + data[i * channels + 0] = table[0][idx]; + data[i * channels + 1] = numColumns > 1 ? table[1][idx] : 0; + data[i * channels + 2] = numColumns > 2 ? table[2][idx] : 0; + data[i * channels + 3] = numColumns > 3 ? table[3][idx] : 255; + } + return data; +} + +function buildSHTableMap(shCoeffs: number) { + const result: number[] = []; + for (let i = 0; i < 3; i++) { + for (let j = 0; j < shCoeffs; j++) { + result.push(j * 3 + i); + } + } + return result; +} + +export class SogFile implements IFile { + private counts: number = 0; + private shDegree: number = 0; + /** + * @internal + */ + version: number; + /** + * @internal + */ + meta: SogMetadata; + /** + * @internal + */ + refs: Record = {}; + + private cached: Array<{ width: number; height: number; data: Uint8Array }>; + + constructor(readonly iterations: number = 10) {} + + async load(stream: ReadableStream, contentLength: number) { + const buffer = new Uint8Array(contentLength); + const reader = stream.getReader(); + let offset = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer.set(value!, offset); + offset += value!.length; + } + + let metaBuffer: Uint8Array = buffer; + const view = new DataView(buffer.buffer); + if (view.getUint32(0, true) === ZIP_MAGIC) { + this.refs = extractFromRootDir(unzipSync(buffer)); + metaBuffer = this.refs['meta.json']; + if (!metaBuffer) { + throw new Error('SOG meta.json not found in the zip archive.'); + } + } + + this.meta = JSON.parse(new TextDecoder().decode(metaBuffer)); + if (this.meta.version === undefined) { + const { means, quats, shN } = this.meta as SogMetadataV1; + if (quats.encoding !== 'quaternion_packed') { + throw new Error('Unsupported quaternion encoding'); + } + this.counts = means.shape[0]; + this.shDegree = shN ? NUM_F_REST_TO_SH_DEGREE[shN.shape[1]] : 0; + this.version = 1; + } else { + const { version, count, shN } = this.meta as SogMetadataV2; + if (version !== 2) { + throw new Error(`Unsupported SOGS version: ${version}`); + } + this.counts = count; + this.shDegree = shN?.bands ?? 0; + this.version = version; + } + } + + private parse_v1(data: SplatData, offset: number) { + const setFn = data.set.bind(data); + const setShFn = data.setShN.bind(data); + + const { meta, counts, shDegree, cached } = this; + const [mean0, mean1, scale0, quat0, color0, centroids, labels] = cached.map(v => v.data); + const { + means: { + mins: [centerMinX, centerMinY, centerMinZ], + maxs: [centerMaxX, centerMaxY, centerMaxZ], + }, + scales: { + mins: [scaleMinX, scaleMinY, scaleMinZ], + maxs: [scaleMaxX, scaleMaxY, scaleMaxZ], + }, + sh0: { + mins: [colorMinR, colorMinG, colorMinB, colorMinA], + maxs: [colorMaxR, colorMaxG, colorMaxB, colorMaxA], + }, + shN, + } = meta as SogMetadataV1; + + const rangeX = (centerMaxX - centerMinX) / 65535; + const rangeY = (centerMaxY - centerMinY) / 65535; + const rangeZ = (centerMaxZ - centerMinZ) / 65535; + + const SX_LUT = new Float32Array(256); + const SY_LUT = new Float32Array(256); + const SZ_LUT = new Float32Array(256); + const scaleRangeX = (scaleMaxX - scaleMinX) / 255; + const scaleRangeY = (scaleMaxY - scaleMinY) / 255; + const scaleRangeZ = (scaleMaxZ - scaleMinZ) / 255; + for (let i = 0; i < 256; i++) { + SX_LUT[i] = Math.exp(scaleMinX + scaleRangeX * i); + SY_LUT[i] = Math.exp(scaleMinY + scaleRangeY * i); + SZ_LUT[i] = Math.exp(scaleMinZ + scaleRangeZ * i); + } + + const A_LUT = new Float32Array(256); + const colorRangeR = (colorMaxR - colorMinR) / 255; + const colorRangeG = (colorMaxG - colorMinG) / 255; + const colorRangeB = (colorMaxB - colorMinB) / 255; + const colorRangeA = (colorMaxA - colorMinA) / 255; + for (let i = 0; i < 256; i++) { + A_LUT[i] = 1.0 / (1.0 + Math.exp(-(colorMinA + colorRangeA * i))); + } + + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: [], + }; + for (let i = 0; i < counts; i++) { + const i4 = i * 4; + + const x = centerMinX + rangeX * (mean0[i4 + 0] + (mean1[i4 + 0] << 8)); + const y = centerMinY + rangeY * (mean0[i4 + 1] + (mean1[i4 + 1] << 8)); + const z = centerMinZ + rangeZ * (mean0[i4 + 2] + (mean1[i4 + 2] << 8)); + single.x = Math.sign(x) * (Math.exp(Math.abs(x)) - 1); + single.y = Math.sign(y) * (Math.exp(Math.abs(y)) - 1); + single.z = Math.sign(z) * (Math.exp(Math.abs(z)) - 1); + + single.sx = SX_LUT[scale0[i4 + 0]]; + single.sy = SY_LUT[scale0[i4 + 1]]; + single.sz = SZ_LUT[scale0[i4 + 2]]; + + TEMP_ROT[0] = (quat0[i4 + 0] / 255 - 0.5) * Math.SQRT2; + TEMP_ROT[1] = (quat0[i4 + 1] / 255 - 0.5) * Math.SQRT2; + TEMP_ROT[2] = (quat0[i4 + 2] / 255 - 0.5) * Math.SQRT2; + TEMP_ROT[3] = Math.sqrt( + Math.max(0, 1.0 - TEMP_ROT[0] * TEMP_ROT[0] - TEMP_ROT[1] * TEMP_ROT[1] - TEMP_ROT[2] * TEMP_ROT[2]), + ); + const PERM = PERM_TABLE[quat0[i4 + 3] - 252]; + single.qx = TEMP_ROT[PERM[0]]; + single.qy = TEMP_ROT[PERM[1]]; + single.qz = TEMP_ROT[PERM[2]]; + single.qw = TEMP_ROT[PERM[3]]; + + single.r = SH_C0 * (colorMinR + colorRangeR * color0[i4 + 0]) + 0.5; + single.g = SH_C0 * (colorMinG + colorRangeG * color0[i4 + 1]) + 0.5; + single.b = SH_C0 * (colorMinB + colorRangeB * color0[i4 + 2]) + 0.5; + single.a = A_LUT[color0[i4 + 3]]; + + setFn(offset + i, single); + } + + if (shN) { + const centroidTexWidth = cached[5].width; + const { mins: min, maxs: max } = shN; + const range = (max - min) / 255; + const shCounts = SH_MAPS[shDegree]; + const sh = new Array(shCounts); + const shCoeffs = shCounts / 3; + for (let i = 0; i < counts; i++) { + const i4 = i * 4; + const label = labels[i4] + (labels[i4 + 1] << 8); + const o = ((label >>> 6) * centroidTexWidth + (label & 63) * 15) * 4; + for (let j = 0; j < shCoeffs; j++) { + sh[j * 3 + 0] = min + range * centroids[o + j * 4 + 0]; + sh[j * 3 + 1] = min + range * centroids[o + j * 4 + 1]; + sh[j * 3 + 2] = min + range * centroids[o + j * 4 + 2]; + } + setShFn(offset + i, sh); + } + } + } + + private parse_v2(data: SplatData, offset: number) { + const setFn = data.set.bind(data); + const setShFn = data.setShN.bind(data); + + const { meta, counts, shDegree, cached } = this; + const { means, scales, sh0, shN } = meta as SogMetadataV2; + const { + mins: [centerMinX, centerMinY, centerMinZ], + maxs: [centerMaxX, centerMaxY, centerMaxZ], + } = means; + const { codebook: scaleCodebook } = scales; + const { codebook: sh0Codebook } = sh0; + const [mean0, mean1, scale0, quat0, color0, centroids, labels] = cached.map(img => img.data); + + const rangeX = (centerMaxX - centerMinX) / 65535; + const rangeY = (centerMaxY - centerMinY) / 65535; + const rangeZ = (centerMaxZ - centerMinZ) / 65535; + const SCALE_LUT = scaleCodebook.map(v => Math.exp(v)); + + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: [], + }; + for (let i = 0; i < counts; i++) { + const i4 = i * 4; + + const x = centerMinX + rangeX * (mean0[i4 + 0] + (mean1[i4 + 0] << 8)); + const y = centerMinY + rangeY * (mean0[i4 + 1] + (mean1[i4 + 1] << 8)); + const z = centerMinZ + rangeZ * (mean0[i4 + 2] + (mean1[i4 + 2] << 8)); + single.x = Math.sign(x) * (Math.exp(Math.abs(x)) - 1); + single.y = Math.sign(y) * (Math.exp(Math.abs(y)) - 1); + single.z = Math.sign(z) * (Math.exp(Math.abs(z)) - 1); + + single.sx = SCALE_LUT[scale0[i4 + 0]]; + single.sy = SCALE_LUT[scale0[i4 + 1]]; + single.sz = SCALE_LUT[scale0[i4 + 2]]; + + TEMP_ROT[0] = (quat0[i4 + 0] / 255 - 0.5) * Math.SQRT2; + TEMP_ROT[1] = (quat0[i4 + 1] / 255 - 0.5) * Math.SQRT2; + TEMP_ROT[2] = (quat0[i4 + 2] / 255 - 0.5) * Math.SQRT2; + TEMP_ROT[3] = Math.sqrt( + Math.max(0, 1.0 - TEMP_ROT[0] * TEMP_ROT[0] - TEMP_ROT[1] * TEMP_ROT[1] - TEMP_ROT[2] * TEMP_ROT[2]), + ); + const PERM = PERM_TABLE[quat0[i4 + 3] - 252]; + single.qx = TEMP_ROT[PERM[0]]; + single.qy = TEMP_ROT[PERM[1]]; + single.qz = TEMP_ROT[PERM[2]]; + single.qw = TEMP_ROT[PERM[3]]; + + single.r = SH_C0 * sh0Codebook[color0[i4 + 0]] + 0.5; + single.g = SH_C0 * sh0Codebook[color0[i4 + 1]] + 0.5; + single.b = SH_C0 * sh0Codebook[color0[i4 + 2]] + 0.5; + single.a = color0[i4 + 3] / 255; + + setFn(offset + i, single); + } + + if (shN) { + const { codebook } = shN; + const shCounts = SH_MAPS[shDegree]; + const shCoeffs = shCounts / 3; + const offsetItemSize = shCoeffs * 4; + const sh = new Array(shCounts); + for (let i = 0; i < counts; i++) { + const i4 = i * 4; + const o = (labels[i4 + 0] + (labels[i4 + 1] << 8)) * offsetItemSize; + for (let j = 0; j < shCoeffs; j++) { + sh[j * 3] = codebook[centroids[o + j * 4 + 0]]; + sh[j * 3 + 1] = codebook[centroids[o + j * 4 + 1]]; + sh[j * 3 + 2] = codebook[centroids[o + j * 4 + 2]]; + } + setShFn(offset + i, sh); + } + } + } + + private async loadTexture(path: string) { + let buffer: Uint8Array | undefined = this.refs[path]; + if (!buffer) { + if (isUrl(path)) { + buffer = await fetch(path) + .then(res => res.arrayBuffer()) + .then(buf => new Uint8Array(buf)); + } + } + if (!buffer) { + throw new Error(`Cannot load texture: ${path}`); + } + + const { data, width, height } = decodeWebP(buffer); + return { + data: new Uint8Array(data), + width, + height, + }; + } + + async read(stream: ReadableStream, contentLength: number, data: SplatData) { + await this.load(stream, contentLength); + const BlockOffset = await data.initBlock(this.counts, this.shDegree); + + const { means, scales, quats, sh0, shN } = this.meta; + this.cached = await Promise.all( + [ + means.files[0], + means.files[1], + scales.files[0], + quats.files[0], + sh0.files[0], + shN?.files[0], + shN?.files[1], + ] + .filter(path => !!path) + .map(path => this.loadTexture(path!)), + ); + + if (this.version === 1) { + this.parse_v1(data, BlockOffset); + } else if (this.version === 2) { + this.parse_v2(data, BlockOffset); + } else { + throw new Error(`Unsupported SOG version: ${this.version}`); + } + data.finishBlock(); + } + + async write(stream: WritableStream, data: SplatData, indices: Uint32Array = mortonSort(data)) { + const { counts, shDegree, shCounts, table } = data; + const width = Math.ceil(Math.sqrt(counts) / 4) * 4; + const height = Math.ceil(counts / width / 4) * 4; + const channels = 4; + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: new Array(shCounts), + }; + const webPProfile = new WebPLosslessProfile(); + const output: Zippable = {}; + const meta: SogMetadataV2 = { + version: 2, + count: counts, + means: { + mins: [], + maxs: [], + files: ['means_l.webp', 'means_u.webp'], + }, + scales: { + codebook: [], + files: ['scales.webp'], + }, + quats: { + files: ['quats.webp'], + }, + sh0: { + codebook: [], + files: ['sh0.webp'], + }, + }; + + // means + { + logger.time('SOG encoding means'); + const xCol = table[ColIdx.x]; + const yCol = table[ColIdx.y]; + const zCol = table[ColIdx.z]; + + // calculate minmax & transform + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + for (let i = 0; i < counts; i++) { + const idx = indices[i]; + const x = xCol[idx]; + const y = yCol[idx]; + const z = zCol[idx]; + if (x < minX) { + minX = x; + } + if (x > maxX) { + maxX = x; + } + if (y < minY) { + minY = y; + } + if (y > maxY) { + maxY = y; + } + if (z < minZ) { + minZ = z; + } + if (z > maxZ) { + maxZ = z; + } + } + minX = logTransform(minX); + minY = logTransform(minY); + minZ = logTransform(minZ); + maxX = logTransform(maxX); + maxY = logTransform(maxY); + maxZ = logTransform(maxZ); + const scaleX = 65535 / Math.max(maxX - minX, 1e-9); + const scaleY = 65535 / Math.max(maxY - minY, 1e-9); + const scaleZ = 65535 / Math.max(maxZ - minZ, 1e-9); + + // encode means + const meansL = new Uint8Array(width * height * channels).fill(0xff); + const meansU = new Uint8Array(width * height * channels).fill(0xff); + for (let i = 0; i < indices.length; i++) { + const idx = indices[i]; + const x = (logTransform(xCol[idx]) - minX) * scaleX; + const y = (logTransform(yCol[idx]) - minY) * scaleY; + const z = (logTransform(zCol[idx]) - minZ) * scaleZ; + meansL[i * 4 + 0] = x & 0xff; + meansL[i * 4 + 1] = y & 0xff; + meansL[i * 4 + 2] = z & 0xff; + meansU[i * 4 + 0] = (x >> 8) & 0xff; + meansU[i * 4 + 1] = (y >> 8) & 0xff; + meansU[i * 4 + 2] = (z >> 8) & 0xff; + } + + output['means_l.webp'] = encodeWebP(meansL, width, height, webPProfile); + output['means_u.webp'] = encodeWebP(meansU, width, height, webPProfile); + meta.means.mins = [minX, minY, minZ]; + meta.means.maxs = [maxX, maxY, maxZ]; + logger.timeEnd('SOG encoding means'); + } + + // quaternions + { + logger.time('SOG encoding quaternions'); + const quats = new Uint8Array(width * height * channels); + const q = [0, 0, 0, 0]; + for (let i = 0; i < indices.length; ++i) { + data.getQuat(indices[i], single); + q[0] = single.qw; + q[1] = single.qx; + q[2] = single.qy; + q[3] = single.qz; + const l = Math.sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]); + + // normalize + q.forEach((v, j) => { + q[j] = v / l; + }); + + // find max component + const maxComp = q.reduce((v, _, i) => (Math.abs(q[i]) > Math.abs(q[v]) ? i : v), 0); + // invert if max component is negative + if (q[maxComp] < 0) { + q.forEach((_, j) => { + q[j] *= -1; + }); + } + + // scale by sqrt(2) to fit in [-1, 1] range + q.forEach((_, j) => (q[j] *= Math.SQRT2)); + + const idx = [ + [1, 2, 3], + [0, 2, 3], + [0, 1, 3], + [0, 1, 2], + ][maxComp]; + quats[i * 4] = (q[idx[0]] * 0.5 + 0.5) * 255; + quats[i * 4 + 1] = (q[idx[1]] * 0.5 + 0.5) * 255; + quats[i * 4 + 2] = (q[idx[2]] * 0.5 + 0.5) * 255; + quats[i * 4 + 3] = 252 + maxComp; + } + + output['quats.webp'] = encodeWebP(quats, width, height, webPProfile); + logger.timeEnd('SOG encoding quaternions'); + } + + // scales + { + logger.time('SOG encoding scales'); + const scaleData = quantize1d( + [table[ColIdx.sx], table[ColIdx.sy], table[ColIdx.sz]], + undefined, + undefined, + Math.log, + ); + const tableData = writeTableData(scaleData.labels, indices, width, height, channels); + output['scales.webp'] = encodeWebP(tableData, width, height, webPProfile); + meta.scales.codebook = Array.from(scaleData.centroids); + logger.timeEnd('SOG encoding scales'); + } + + // colors + { + logger.time('SOG encoding colors'); + const colorData = quantize1d( + [table[ColIdx.r], table[ColIdx.g], table[ColIdx.b]], + undefined, + undefined, + v => (v - 0.5) / SH_C0, + ); + const aCol = table[ColIdx.a]; + const opacityData = new Uint8Array(aCol.length); + for (let i = 0; i < counts; ++i) { + opacityData[i] = clamp(aCol[i] * 255, 0, 255); + } + colorData.labels.push(opacityData); + const tableData = writeTableData(colorData.labels, indices, width, height, channels); + output['sh0.webp'] = encodeWebP(tableData, width, height, webPProfile); + meta.sh0.codebook = Array.from(colorData.centroids); + logger.timeEnd('SOG encoding colors'); + } + + // SH + if (shDegree > 0) { + logger.time(`SOG encoding SH${shDegree}`); + const shCoeffs = shCounts / 3; + const shDataTable: Float32Array[] = []; + for (const i of buildSHTableMap(shCoeffs)) { + shDataTable.push(table[ColIdx.shOffset + i]); + } + const paletteSize = Math.min(64, 2 ** Math.floor(Math.log2(indices.length / 1024))) * 1024; + const device = await getOrCreateDevice(); + + logger.info(`SOG SH${shDegree} k-means with clusters=${paletteSize} iterations=${this.iterations}`); + logger.time(`SOG SH${shDegree} k-means`); + const { centroids, labels } = await kMeans(shDataTable, paletteSize, this.iterations, device); + logger.timeEnd(`SOG SH${shDegree} k-means`); + + const codebook = quantize1d(centroids); + // write centroids + const centroidsBuf = new Uint8Array(64 * shCoeffs * Math.ceil(centroids[0].length / 64) * channels).fill( + 0xff, + ); + const centroidsRow: number[] = []; + for (let i = 0; i < centroids[0].length; ++i) { + codebook.labels.forEach((column, index) => { + centroidsRow[index] = column[i]; + }); + for (let j = 0; j < shCoeffs; ++j) { + centroidsBuf[i * shCoeffs * 4 + j * 4 + 0] = centroidsRow[shCoeffs * 0 + j]; + centroidsBuf[i * shCoeffs * 4 + j * 4 + 1] = centroidsRow[shCoeffs * 1 + j]; + centroidsBuf[i * shCoeffs * 4 + j * 4 + 2] = centroidsRow[shCoeffs * 2 + j]; + } + } + output['shN_centroids.webp'] = encodeWebP( + centroidsBuf, + 64 * shCoeffs, + Math.ceil(centroids[0].length / 64), + webPProfile, + ); + + // write labels + const labelsBuf = new Uint8Array(width * height * channels).fill(0xff); + for (let i = 0; i < indices.length; ++i) { + const label = labels[indices[i]]; + labelsBuf[i * 4 + 0] = label & 0xff; + labelsBuf[i * 4 + 1] = (label >> 8) & 0xff; + } + output['shN_labels.webp'] = encodeWebP(labelsBuf, width, height, webPProfile); + meta.shN = { + count: paletteSize, + bands: shDegree, + codebook: Array.from(codebook.centroids), + files: ['shN_centroids.webp', 'shN_labels.webp'], + }; + logger.timeEnd(`SOG encoding SH${shDegree}`); + } + output['meta.json'] = Buffer.from(JSON.stringify(meta), 'utf-8'); + const result = zipSync(output); + await stream.getWriter().write(result); + } +} diff --git a/packages/splat-transform/src/file/splat.ts b/packages/splat-transform/src/file/splat.ts new file mode 100644 index 0000000..0eb6d71 --- /dev/null +++ b/packages/splat-transform/src/file/splat.ts @@ -0,0 +1,131 @@ +import type { ISingleSplat, SplatData } from '../SplatData.js'; +import { clamp, StreamChunkDecoder, BufferReader, mortonSort } from '../utils/index.js'; +import type { IFile } from './IFile.js'; + +const ItemSize = 32; +export class SplatFile implements IFile { + async read(stream: ReadableStream, contentLength: number, data: SplatData) { + const setFn = data.set.bind(data) as SplatData['set']; + const counts = Math.floor(contentLength / ItemSize); + const BlockOffset = await data.initBlock(counts, 0); + + const reader = new BufferReader(); + const decoder = new StreamChunkDecoder(reader); + + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: [], + }; + decoder.setDecoders([ + { + init: () => [counts, ItemSize], + decode: (offset, counts, buffer) => { + offset += BlockOffset; + const f32Array = new Float32Array(buffer.buffer); + let o = 0; + for (let i = 0; i < counts; i++) { + o = i * 8; + single.x = f32Array[o]; + single.y = f32Array[o + 1]; + single.z = f32Array[o + 2]; + single.sx = f32Array[o + 3]; + single.sy = f32Array[o + 4]; + single.sz = f32Array[o + 5]; + o = i * 32; + single.r = buffer[o + 24] / 255; + single.g = buffer[o + 25] / 255; + single.b = buffer[o + 26] / 255; + single.a = buffer[o + 27] / 255; + single.qw = (buffer[o + 28] - 128) / 128; + single.qx = (buffer[o + 29] - 128) / 128; + single.qy = (buffer[o + 30] - 128) / 128; + single.qz = (buffer[o + 31] - 128) / 128; + setFn(offset + i, single); + } + }, + }, + ]); + + const source = stream.getReader(); + while (true) { + const { done, value } = await source.read(); + if (done) { + break; + } + reader.write(value!); + decoder.flush(); + } + data.finishBlock(); + } + + async write(stream: WritableStream, data: SplatData, indices: Uint32Array = mortonSort(data)) { + const writer = stream.getWriter(); + + const chunkSize = 2048; + const chunkCounts = Math.ceil(data.counts / chunkSize); + + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: [], + }; + for (let i = 0; i < chunkCounts; i++) { + if (writer.desiredSize! <= 0) { + await writer.ready; + } + + const currentChunkSize = Math.min(chunkSize, data.counts - i * chunkSize); + const chunk = new Uint8Array(currentChunkSize * ItemSize); + const dataView = new DataView(chunk.buffer); + const offset = i * chunkSize; + for (let j = 0; j < currentChunkSize; j++) { + data.get(indices[offset + j], single); + const o = j * ItemSize; + dataView.setFloat32(o, single.x, true); + dataView.setFloat32(o + 4, single.y, true); + dataView.setFloat32(o + 8, single.z, true); + dataView.setFloat32(o + 12, single.sx, true); + dataView.setFloat32(o + 16, single.sy, true); + dataView.setFloat32(o + 20, single.sz, true); + dataView.setUint8(o + 24, clamp(Math.round(single.r * 255), 0, 255)); + dataView.setUint8(o + 25, clamp(Math.round(single.g * 255), 0, 255)); + dataView.setUint8(o + 26, clamp(Math.round(single.b * 255), 0, 255)); + dataView.setUint8(o + 27, clamp(Math.round(single.a * 255), 0, 255)); + dataView.setUint8(o + 28, clamp(Math.round(single.qw * 128 + 128), 0, 255)); + dataView.setUint8(o + 29, clamp(Math.round(single.qx * 128 + 128), 0, 255)); + dataView.setUint8(o + 30, clamp(Math.round(single.qy * 128 + 128), 0, 255)); + dataView.setUint8(o + 31, clamp(Math.round(single.qz * 128 + 128), 0, 255)); + } + + writer.write(chunk); + await Promise.resolve(); + } + + await writer.close(); + } +} diff --git a/packages/splat-transform/src/file/spz.ts b/packages/splat-transform/src/file/spz.ts new file mode 100644 index 0000000..2ff653a --- /dev/null +++ b/packages/splat-transform/src/file/spz.ts @@ -0,0 +1,679 @@ +import { constants as zlibConstants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'; +import type { ISingleSplat, SplatData } from '../SplatData.js'; +import { SH_C0, SH_MAPS } from '../constant.js'; +import { BufferReader, fromHalf, clamp, StreamChunkDecoder, mortonSort } from '../utils/index.js'; +import type { IFile } from './IFile.js'; + +const SPZ_MAGIC = 0x5053474e; // NGSP = Niantic gaussian splat +const SPZ_VERSION = 3; +const ZSTD_COMPRESSION_LEVEL = 12; +const FLAG_ANTIALIASED = 0x1; + +const COLOR_SCALE = SH_C0 / 0.15; +const rotation: number[] = new Array(4); +const SH_SCALE1 = 1 << 3; +const SH_SCALE2 = 1 << 4; +export class SpzFile implements IFile { + readonly compressLevel: number; + readonly spzVersion: number; + + constructor(compressLevel: number, spzVersion: number = SPZ_VERSION) { + if (spzVersion !== 3 && spzVersion !== 4) { + throw new Error(`Unsupported SPZ version: ${spzVersion}`); + } + this.compressLevel = compressLevel; + this.spzVersion = spzVersion; + } + + async read(stream: ReadableStream, _contentLength: number, data: SplatData) { + const setCenter = data.setCenter.bind(data) as SplatData['setCenter']; + const setAlpha = data.setAlpha.bind(data) as SplatData['setAlpha']; + const setColor = data.setColor.bind(data) as SplatData['setColor']; + const setScale = data.setScale.bind(data) as SplatData['setScale']; + const setQuat = data.setQuat.bind(data) as SplatData['setQuat']; + const setShN = data.setShN.bind(data) as SplatData['setShN']; + + const SCALE_LUT = new Float32Array(256); + for (let i = 0; i < 256; i++) { + SCALE_LUT[i] = Math.exp(i / 16 - 10); + } + const COLOR_LUT = new Float32Array(256); + for (let i = 0; i < 256; i++) { + COLOR_LUT[i] = (i / 255 - 0.5) * COLOR_SCALE + 0.5; + } + + let version: number = SPZ_VERSION; + let counts: number = 0; + let shDegree: number = 0; + let fractionalBits: number = 12; + let flags: number = FLAG_ANTIALIASED; + let reserved: number = 0; + + let isF16 = false; + let useSmallestThreeQuat = true; + let fraction = 1; + let fractionInv = 1; + let shCounts = 0; + let BlockOffset: number = 0; + const shN: number[] = []; + + const reader = new BufferReader(); + const decoder = new StreamChunkDecoder(reader); + decoder.setDecoders([ + { + init: () => [1, 16], + decode: async (_offset, _counts, buf) => { + const header = new DataView(buf.buffer); + if (header.getUint32(0, true) !== SPZ_MAGIC) { + throw new Error('Invalid SPZ file'); + } + ({ version, counts, shDegree, fractionalBits, flags, extra: reserved } = readSpzHeader(header)); + if (version < 1 || version > 3) { + throw new Error(`Unsupported SPZ version: ${version}`); + } + + isF16 = version < 2; + useSmallestThreeQuat = version >= 3; + fraction = 1 << fractionalBits; + fractionInv = 1 / fraction; + shCounts = SH_MAPS[shDegree]; + + BlockOffset = await data.initBlock(counts, shDegree); + if (flags || reserved) { + // + } + }, + }, + { + init: () => [counts, isF16 ? 6 : 9], + decode: (offset, counts, buf) => { + offset += BlockOffset; + let x: number, y: number, z: number; + for (let i = 0; i < counts; i++) { + if (isF16) { + const o = i * 6; + x = fromHalf((buf[o + 1] << 8) | buf[o]); + y = fromHalf((buf[o + 3] << 8) | buf[o + 2]); + z = fromHalf((buf[o + 5] << 8) | buf[o + 4]); + } else { + const o = i * 9; + x = (((buf[o + 2] << 24) | (buf[o + 1] << 16) | (buf[o] << 8)) >> 8) * fractionInv; + y = (((buf[o + 5] << 24) | (buf[o + 4] << 16) | (buf[o + 3] << 8)) >> 8) * fractionInv; + z = (((buf[o + 8] << 24) | (buf[o + 7] << 16) | (buf[o + 6] << 8)) >> 8) * fractionInv; + } + setCenter(offset + i, x, y, z); + } + }, + }, + { + init: () => [counts, 1], + decode: (offset, counts, buf) => { + offset += BlockOffset; + for (let i = 0; i < counts; i++) { + setAlpha(offset + i, buf[i] / 255); + } + }, + }, + { + init: () => [counts, 3], + decode: (offset, counts, buf) => { + offset += BlockOffset; + for (let i = 0; i < counts; i++) { + const o = i * 3; + setColor(offset + i, COLOR_LUT[buf[o]], COLOR_LUT[buf[o + 1]], COLOR_LUT[buf[o + 2]]); + } + }, + }, + { + init: () => [counts, 3], + decode: (offset, counts, buf) => { + offset += BlockOffset; + for (let i = 0; i < counts; i++) { + const o = i * 3; + setScale(offset + i, SCALE_LUT[buf[o]], SCALE_LUT[buf[o + 1]], SCALE_LUT[buf[o + 2]]); + } + }, + }, + { + init: () => [counts, useSmallestThreeQuat ? 4 : 3], + decode: (offset, counts, buf) => { + offset += BlockOffset; + let qx: number, qy: number, qz: number, qw: number; + for (let i = 0; i < counts; i++) { + if (!useSmallestThreeQuat) { + const o = i * 3; + qx = buf[o] / 127.5 - 1; + qy = buf[o + 1] / 127.5 - 1; + qz = buf[o + 2] / 127.5 - 1; + qw = Math.sqrt(Math.max(0, 1 - qx * qx - qy * qy - qz * qz)); + } else { + const o = i * 4; + const packed = buf[o] | (buf[o + 1] << 8) | (buf[o + 2] << 16) | (buf[o + 3] << 24); + + const largest = packed >>> 30; + let temp = packed; + let sum = 0; + for (let j = 3; j >= 0; j--) { + if (j === largest) { + continue; + } + const mag = temp & 0x1ff; + const sign = (temp >>> 9) & 1; + temp >>>= 10; + + const v = Math.SQRT1_2 * (mag / 0x1ff) * (sign ? -1 : 1); + rotation[j] = v; + sum += v * v; + } + rotation[largest] = Math.sqrt(1 - sum); + qx = rotation[0]; + qy = rotation[1]; + qz = rotation[2]; + qw = rotation[3]; + } + setQuat(offset + i, qx, qy, qz, qw); + } + }, + }, + { + init: () => [counts, shCounts], + decode: (offset, counts, buf) => { + offset += BlockOffset; + for (let i = 0; i < counts; i++) { + const o = i * shCounts; + for (let j = 0; j < shCounts; j++) { + shN[j] = (buf[o + j] - 128) / 128; + } + setShN(offset + i, shN); + } + }, + }, + ]); + + const peeked = await peekStream(stream, 8); + stream = peeked.stream; + if (isSpzV4(peeked.prefix)) { + await readSpzV4Stream(stream, reader, decoder); + data.finishBlock(); + return; + } + + let source: ReadableStreamDefaultReader; + if (this.compressLevel === -1) { + source = stream.getReader(); + } else { + source = stream + .pipeThrough(new DecompressionStream('gzip') as TransformStream) + .getReader(); + } + while (true) { + const { done, value } = await source.read(); + if (done) { + break; + } + reader.write(value!); + decoder.flush(); + } + data.finishBlock(); + } + + async write(writeStream: WritableStream, data: SplatData, indices: Uint32Array = mortonSort(data)) { + if (this.spzVersion === 4) { + await this.writeV4(writeStream, data, indices); + } else { + await this.writeV3(writeStream, data, indices); + } + } + + private async writeV3(writeStream: WritableStream, data: SplatData, indices: Uint32Array) { + let writer: WritableStreamDefaultWriter; + let pipePromise: Promise; + if (this.compressLevel === -1) { + writer = writeStream.getWriter(); + pipePromise = Promise.resolve(); + } else { + const compressStream = new CompressionStream('gzip') as TransformStream; + pipePromise = compressStream.readable.pipeTo(writeStream); + writer = compressStream.writable.getWriter(); + } + + const version: number = SPZ_VERSION; + const counts: number = data.counts; + const shDegree: number = data.shDegree; + const fractionalBits: number = 12; + const flags: number = FLAG_ANTIALIASED; + + const shCounts = getShCounts(shDegree); + const context = createSpzEncodeContext(data, indices, fractionalBits, shCounts); + + // header + writer.write(createSpzHeader(version, counts, shDegree, fractionalBits, flags, 0)); + + for (const attribute of getSpzAttributes(shDegree)) { + await writeSpzAttribute(writer, context, attribute); + } + + await writer.close(); + await pipePromise; + } + + private async writeV4(writeStream: WritableStream, data: SplatData, indices: Uint32Array) { + const version: number = 4; + const counts: number = data.counts; + const shDegree: number = data.shDegree; + const fractionalBits: number = 12; + const flags: number = FLAG_ANTIALIASED; + + const shCounts = getShCounts(shDegree); + const context = createSpzEncodeContext(data, indices, fractionalBits, shCounts); + const compressed: Uint8Array[] = []; + const uncompressedSizes: number[] = []; + + for (const attribute of getSpzAttributes(shDegree)) { + const chunk = createSpzAttributeChunk(context, attribute, 0, counts); + uncompressedSizes.push(chunk.byteLength); + compressed.push( + zstdCompressSync(chunk, { + params: { + [zlibConstants.ZSTD_c_compressionLevel]: ZSTD_COMPRESSION_LEVEL, + }, + }), + ); + } + + const tocByteOffset = 32; + const tocSize = compressed.length * 16; + const header = createSpzHeader(version, counts, shDegree, fractionalBits, flags, compressed.length, 32); + new DataView(header.buffer).setUint32(16, tocByteOffset, true); + const toc = new Uint8Array(tocSize); + const tocView = new DataView(toc.buffer); + + for (let i = 0; i < compressed.length; i++) { + const entryOffset = i * 16; + writeUint64(tocView, entryOffset, compressed[i].byteLength); + writeUint64(tocView, entryOffset + 8, uncompressedSizes[i]); + } + + const writer = writeStream.getWriter(); + await writer.write(header); + await writer.write(toc); + for (const chunk of compressed) { + await writer.write(chunk); + } + await writer.close(); + } +} + +type SpzAttribute = 'position' | 'alpha' | 'color' | 'scale' | 'quat' | 'sh'; + +interface SpzEncodeContext { + data: SplatData; + indices: Uint32Array; + fractionalBits: number; + fraction: number; + shCounts: number; + single: ISingleSplat; +} + +function getShCounts(shDegree: number) { + const shCounts = SH_MAPS[shDegree]; + if (shCounts === undefined) { + throw new Error(`Unsupported SPZ SH degree: ${shDegree}`); + } + return shCounts; +} + +function createSpzEncodeContext( + data: SplatData, + indices: Uint32Array, + fractionalBits: number, + shCounts: number, +): SpzEncodeContext { + return { + data, + indices, + fractionalBits, + fraction: 1 << fractionalBits, + shCounts, + single: { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: new Array(shCounts), + }, + }; +} + +function getSpzAttributes(shDegree: number): SpzAttribute[] { + return shDegree > 0 + ? ['position', 'alpha', 'color', 'scale', 'quat', 'sh'] + : ['position', 'alpha', 'color', 'scale', 'quat']; +} + +function getSpzAttributeInfo(attribute: SpzAttribute, shCounts: number) { + switch (attribute) { + case 'position': + return { itemSize: 9, chunkSize: 4096 }; + case 'alpha': + return { itemSize: 1, chunkSize: 65536 }; + case 'color': + case 'scale': + return { itemSize: 3, chunkSize: 16384 }; + case 'quat': + return { itemSize: 4, chunkSize: 16384 }; + case 'sh': + return { itemSize: shCounts, chunkSize: 1024 }; + } +} + +function createSpzAttributeChunk(context: SpzEncodeContext, attribute: SpzAttribute, offset: number, counts: number) { + const { data, indices, single, shCounts } = context; + const { itemSize } = getSpzAttributeInfo(attribute, shCounts); + const chunk = new Uint8Array(counts * itemSize); + for (let i = 0; i < counts; i++) { + const index = indices[offset + i]; + switch (attribute) { + case 'position': { + data.getCenter(index, single); + const o = i * itemSize; + const ix = clamp(single.x * context.fraction, -0x7fffff, 0x7fffff); + chunk[o + 0] = ix & 0xff; + chunk[o + 1] = (ix >> 8) & 0xff; + chunk[o + 2] = (ix >> 16) & 0xff; + const iy = clamp(single.y * context.fraction, -0x7fffff, 0x7fffff); + chunk[o + 3] = iy & 0xff; + chunk[o + 4] = (iy >> 8) & 0xff; + chunk[o + 5] = (iy >> 16) & 0xff; + const iz = clamp(single.z * context.fraction, -0x7fffff, 0x7fffff); + chunk[o + 6] = iz & 0xff; + chunk[o + 7] = (iz >> 8) & 0xff; + chunk[o + 8] = (iz >> 16) & 0xff; + break; + } + case 'alpha': + data.getAlpha(index, single); + chunk[i] = clamp(Math.round(single.a * 255), 0, 255); + break; + case 'color': { + data.getColor(index, single); + const o = i * itemSize; + chunk[o + 0] = clamp(Math.round(((single.r - 0.5) / COLOR_SCALE + 0.5) * 255), 0, 255); + chunk[o + 1] = clamp(Math.round(((single.g - 0.5) / COLOR_SCALE + 0.5) * 255), 0, 255); + chunk[o + 2] = clamp(Math.round(((single.b - 0.5) / COLOR_SCALE + 0.5) * 255), 0, 255); + break; + } + case 'scale': { + data.getScale(index, single); + const o = i * itemSize; + chunk[o + 0] = clamp(Math.round((Math.log(single.sx) + 10) * 16), 0, 255); + chunk[o + 1] = clamp(Math.round((Math.log(single.sy) + 10) * 16), 0, 255); + chunk[o + 2] = clamp(Math.round((Math.log(single.sz) + 10) * 16), 0, 255); + break; + } + case 'quat': { + data.getQuat(index, single); + const o = i * itemSize; + rotation[0] = single.qx; + rotation[1] = single.qy; + rotation[2] = single.qz; + rotation[3] = single.qw; + let iLargest = 0; + for (let j = 1; j < 4; ++j) { + if (Math.abs(rotation[j]) > Math.abs(rotation[iLargest])) { + iLargest = j; + } + } + const negate = rotation[iLargest] < 0 ? 1 : 0; + let comp = iLargest; + for (let j = 0; j < 4; ++j) { + if (j !== iLargest) { + const negbit = (rotation[j] < 0 ? 1 : 0) ^ negate; + const mag = Math.floor(((1 << 9) - 1) * (Math.abs(rotation[j]) / Math.SQRT1_2) + 0.5); + comp = (comp << 10) | (negbit << 9) | mag; + } + } + chunk[o + 0] = comp & 0xff; + chunk[o + 1] = (comp >> 8) & 0xff; + chunk[o + 2] = (comp >> 16) & 0xff; + chunk[o + 3] = (comp >> 24) & 0xff; + break; + } + case 'sh': { + data.getShN(index, single.shN); + const o = i * itemSize; + for (let j = 0; j < itemSize; j++) { + if (j < 9) { + chunk[o + j] = clamp( + Math.floor((Math.round(single.shN[j] * 128) + 128 + SH_SCALE1 / 2) / SH_SCALE1) * SH_SCALE1, + 0, + 255, + ); + continue; + } + chunk[o + j] = clamp( + Math.floor((Math.round(single.shN[j] * 128) + 128 + SH_SCALE2 / 2) / SH_SCALE2) * SH_SCALE2, + 0, + 255, + ); + } + break; + } + } + } + return chunk; +} + +async function writeSpzAttribute( + writer: WritableStreamDefaultWriter, + context: SpzEncodeContext, + attribute: SpzAttribute, +) { + const { chunkSize } = getSpzAttributeInfo(attribute, context.shCounts); + const chunkCounts = Math.ceil(context.data.counts / chunkSize); + for (let i = 0; i < chunkCounts; i++) { + if (writer.desiredSize! <= 0) { + await writer.ready; + } + const offset = i * chunkSize; + const counts = Math.min(chunkSize, context.data.counts - offset); + writer.write(createSpzAttributeChunk(context, attribute, offset, counts)); + } +} + +function readUint64(view: DataView, offset: number) { + const low = view.getUint32(offset, true); + const high = view.getUint32(offset + 4, true); + const value = high * 0x100000000 + low; + if (!Number.isSafeInteger(value)) { + throw new Error(`SPZ stream size is too large: ${value}`); + } + return value; +} + +function writeUint64(view: DataView, offset: number, value: number) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Invalid SPZ stream size: ${value}`); + } + view.setUint32(offset, value >>> 0, true); + view.setUint32(offset + 4, Math.floor(value / 0x100000000), true); +} + +function createSpzHeader( + version: number, + counts: number, + shDegree: number, + fractionalBits: number, + flags: number, + extra: number, + byteLength = 16, +) { + const header = new DataView(new ArrayBuffer(byteLength)); + header.setUint32(0, SPZ_MAGIC, true); + header.setUint32(4, version, true); + header.setUint32(8, counts, true); + header.setUint8(12, shDegree); + header.setUint8(13, fractionalBits); + header.setUint8(14, flags); + header.setUint8(15, extra); + return new Uint8Array(header.buffer); +} + +function readSpzHeader(view: DataView) { + return { + version: view.getUint32(4, true), + counts: view.getUint32(8, true), + shDegree: view.getUint8(12), + fractionalBits: view.getUint8(13), + flags: view.getUint8(14), + extra: view.getUint8(15), + }; +} + +function getSpzV4AttributeSizes(counts: number, shDegree: number) { + const shCounts = getShCounts(shDegree); + const sizes = [ + counts * 9, // position + counts, // alpha + counts * 3, // color + counts * 3, // scale + counts * 4, // quat + ]; + if (shDegree > 0) { + sizes.push(counts * shCounts); // sh + } + return sizes; +} + +function isSpzV4(buffer: Uint8Array) { + if (buffer.byteLength < 8) { + return false; + } + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + return view.getUint32(0, true) === SPZ_MAGIC && view.getUint32(4, true) === 4; +} + +async function readSpzV4Stream(stream: ReadableStream, reader: BufferReader, decoder: StreamChunkDecoder) { + const read = createExactReader(stream); + const header = await read(32); + const view = new DataView(header.buffer, header.byteOffset, header.byteLength); + const { counts, shDegree, fractionalBits, flags, extra: numStreams } = readSpzHeader(view); + const tocByteOffset = view.getUint32(16, true); + const expectedSizes = getSpzV4AttributeSizes(counts, shDegree); + if (numStreams !== expectedSizes.length) { + throw new Error(`Invalid SPZ v4 stream count: ${numStreams}`); + } + if (tocByteOffset < 32) { + throw new Error(`Invalid SPZ v4 TOC offset: ${tocByteOffset}`); + } + + if (tocByteOffset > 32) { + await read(tocByteOffset - 32); + } + + const toc = await read(numStreams * 16); + const tocView = new DataView(toc.buffer, toc.byteOffset, toc.byteLength); + // Reuse the legacy v3 attribute decoder after parsing the v4 container. + reader.write(createSpzHeader(SPZ_VERSION, counts, shDegree, fractionalBits, flags & FLAG_ANTIALIASED, 0)); + decoder.flush(); + for (let i = 0; i < numStreams; i++) { + const entryOffset = i * 16; + const compressedSize = readUint64(tocView, entryOffset); + const uncompressedSize = readUint64(tocView, entryOffset + 8); + if (uncompressedSize !== expectedSizes[i]) { + throw new Error(`Invalid SPZ v4 stream size at index ${i}`); + } + + const compressed = await read(compressedSize); + const decompressed = zstdDecompressSync(compressed, { + maxOutputLength: uncompressedSize, + }); + if (decompressed.byteLength !== uncompressedSize) { + throw new Error(`Invalid SPZ v4 decompressed size at index ${i}`); + } + reader.write(new Uint8Array(decompressed.buffer, decompressed.byteOffset, decompressed.byteLength)); + decoder.flush(); + } +} + +// Return a reader that resolves exactly byteLength bytes and keeps leftover bytes for the next read. +function createExactReader(stream: ReadableStream) { + const reader = stream.getReader(); + let chunk: Uint8Array | undefined; + let chunkOffset = 0; + return async (byteLength: number) => { + const result = new Uint8Array(byteLength); + let offset = 0; + while (offset < byteLength) { + if (!chunk || chunkOffset >= chunk.byteLength) { + const { done, value } = await reader.read(); + if (done || !value) { + throw new Error('Invalid SPZ v4 file: stream ended unexpectedly'); + } + chunk = value; + chunkOffset = 0; + } + const copyLength = Math.min(byteLength - offset, chunk.byteLength - chunkOffset); + result.set(chunk.subarray(chunkOffset, chunkOffset + copyLength), offset); + chunkOffset += copyLength; + offset += copyLength; + } + return result; + }; +} + +// Peek leading bytes for format detection, then replay the consumed chunks through a replacement stream. +async function peekStream(stream: ReadableStream, byteLength: number) { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (size < byteLength) { + const { done, value } = await reader.read(); + if (done || !value) { + break; + } + chunks.push(value); + size += value.byteLength; + } + const prefix = new Uint8Array(Math.min(size, byteLength)); + let offset = 0; + for (const chunk of chunks) { + const copyLength = Math.min(chunk.byteLength, prefix.byteLength - offset); + prefix.set(chunk.subarray(0, copyLength), offset); + offset += copyLength; + if (offset === prefix.byteLength) { + break; + } + } + return { + prefix, + stream: new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + }, + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + controller.enqueue(value!); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }), + }; +} diff --git a/packages/splat-transform/src/file/voxel.ts b/packages/splat-transform/src/file/voxel.ts new file mode 100644 index 0000000..dffd8a6 --- /dev/null +++ b/packages/splat-transform/src/file/voxel.ts @@ -0,0 +1,828 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { Buffer } from 'node:buffer'; +import { constants as zlibConstants, gzipSync, zstdCompressSync } from 'node:zlib'; + +/** + * Portions of this voxel pipeline are adapted from: + * https://github.com/playcanvas/splat-transform + * Copyright (c) 2011-2026 PlayCanvas Ltd. + * Licensed under the MIT License. + */ +import { ColIdx, type SplatData } from '../SplatData.js'; +import { computeDenseBox, logger, cpuVoxelize, gpuVoxelize, type BlockMaskBuffer } from '../utils/index.js'; +import { encodeCompactVoxelBinary, encodeRawVoxelBinary, type VoxelNodeEncoding } from '../utils/voxel/binary.js'; +import { fillExterior, fillFloor, carve, type NavSeed } from '../utils/voxel/nav.js'; +import { buildCollisionMesh, type CollisionMeshShape } from '../utils/voxel/mesh.js'; +import { cropToNavigable, cropToOccupied, filterAndFillBlocks } from '../utils/voxel/postprocess.js'; +import { + filterCluster, + type FilterClusterOptions, + type FilterClusterRuntimeOptions, +} from '../utils/voxel/filterCluster.js'; +import { + alignGridBounds, + ALPHA_THRESHOLD, + buildSparseOctree, + checkVoxelGridCapacity, + decodeMorton3, + encodeMorton3, + extentsFromQuatScale, + getChildOffset, + MAX_24BIT_OFFSET, + MAX_VOXEL_BLOCK_COUNT_INT32, + SparseOctree24BitOverflowError, + SparseVoxelGrid, + type Bounds, + type SparseOctree, +} from '../utils/voxel/common.js'; +export type VoxelBinaryCompression = 'none' | 'gzip' | 'zstd'; + +interface VoxelMetadata { + version: string; + gridBounds: { min: number[]; max: number[] }; + sceneBounds: { min: number[]; max: number[] }; + voxelResolution: number; + leafSize: number; + treeDepth: number; + numInteriorNodes: number; + numMixedLeaves: number; + nodeCount: number; + leafDataCount: number; + files: string[]; + nodeEncoding: VoxelNodeEncoding; + compression: VoxelBinaryCompression; +} +type VoxelBackend = 'cpu' | 'gpu'; +type CollisionMeshOption = boolean | CollisionMeshShape; +interface BoundsBox { + minCorner: [number, number, number]; + maxCorner: [number, number, number]; +} +export interface AutoDenseBoxOptions { + targetCenterKeepRatio?: number; + minimumCenterKeepRatio?: number; + minimumAxisInflation?: number; + minimumVolumeInflation?: number; +} +export type AutoDenseBoxConfig = boolean | AutoDenseBoxOptions; +interface VoxelWriteResult { + metadata: VoxelMetadata; + binary: Uint8Array; + collisionGlb: Uint8Array | undefined; +} +interface VoxelBinaryOutput { + filename: string; + payload: Uint8Array; +} +// Stay under the hard 31-bit block limit and leave room for CPU memory use. +const GRID_BLOCK_FALLBACK_TARGET = Math.floor(MAX_VOXEL_BLOCK_COUNT_INT32 * 0.55); +const OCTREE_24BIT_FALLBACK_TARGET = Math.floor((MAX_24BIT_OFFSET + 1) * 0.98); +const MAX_RESOLUTION_FALLBACK_ATTEMPTS = 4; +const RESOLUTION_FALLBACK_ALIGNMENT = 0.01; +const ZSTD_COMPRESSION_LEVEL = 12; +const DEFAULT_VOXEL_BOX: BoundsBox = { minCorner: [-100, -100, -100], maxCorner: [100, 100, 100] }; +const DEFAULT_AUTO_DENSE_BOX_TARGET_CENTER_KEEP_RATIO = 0.98; +const DEFAULT_AUTO_DENSE_BOX_MINIMUM_CENTER_KEEP_RATIO = 0.95; +const DEFAULT_AUTO_DENSE_BOX_MINIMUM_AXIS_INFLATION = 1.5; +const DEFAULT_AUTO_DENSE_BOX_MINIMUM_VOLUME_INFLATION = 4; + +function blockCountFromBounds(bounds: Bounds, voxelResolution: number) { + const blockSize = 4 * voxelResolution; + const count = { + x: Math.round((bounds.max.x - bounds.min.x) / blockSize), + y: Math.round((bounds.max.y - bounds.min.y) / blockSize), + z: Math.round((bounds.max.z - bounds.min.z) / blockSize), + }; + return { ...count, total: count.x * count.y * count.z }; +} + +function chooseFallbackResolution(currentVoxelResolution: number, currentCount: number, targetCount: number): number { + // Raise resolution based on how far the count is over target. + // Round up to 0.01 so metadata stays easy to read. + const step = RESOLUTION_FALLBACK_ALIGNMENT; + function align(value: number) { + return Number((Math.ceil((value - 1e-12) / step) * step).toFixed(6)); + } + const scaled = currentVoxelResolution * Math.cbrt(currentCount / targetCount); + const aligned = align(scaled); + if (aligned > currentVoxelResolution) { + return aligned; + } + return align(currentVoxelResolution + step); +} + +function formatBoundsBox(box: BoundsBox) { + const formatPoint = (point: [number, number, number]) => point.map(v => v.toFixed(2)).join(','); + return `(${formatPoint(box.minCorner)})-(${formatPoint(box.maxCorner)})`; +} + +function toBoundsBox(box: { min: number[]; max: number[] }): BoundsBox { + return { + minCorner: [box.min[0], box.min[1], box.min[2]], + maxCorner: [box.max[0], box.max[1], box.max[2]], + }; +} + +function intersectBoundsBoxes(a: BoundsBox, b: BoundsBox): BoundsBox | null { + const minCorner: [number, number, number] = [ + Math.max(a.minCorner[0], b.minCorner[0]), + Math.max(a.minCorner[1], b.minCorner[1]), + Math.max(a.minCorner[2], b.minCorner[2]), + ]; + const maxCorner: [number, number, number] = [ + Math.min(a.maxCorner[0], b.maxCorner[0]), + Math.min(a.maxCorner[1], b.maxCorner[1]), + Math.min(a.maxCorner[2], b.maxCorner[2]), + ]; + if (minCorner[0] >= maxCorner[0] || minCorner[1] >= maxCorner[1] || minCorner[2] >= maxCorner[2]) { + return null; + } + return { minCorner, maxCorner }; +} + +function countCentersInsideBounds(data: SplatData, box: BoundsBox) { + const xCol = data.table[ColIdx.x]; + const yCol = data.table[ColIdx.y]; + const zCol = data.table[ColIdx.z]; + const [minX, minY, minZ] = box.minCorner; + const [maxX, maxY, maxZ] = box.maxCorner; + let count = 0; + for (let i = 0; i < data.counts; i++) { + if ( + xCol[i] >= minX && + xCol[i] <= maxX && + yCol[i] >= minY && + yCol[i] <= maxY && + zCol[i] >= minZ && + zCol[i] <= maxZ + ) { + count++; + } + } + return count; +} + +function computeCenterAndSceneBounds(data: SplatData): { centerBounds: BoundsBox; sceneBounds: BoundsBox } { + if (data.counts === 0) { + return { + centerBounds: { minCorner: [0, 0, 0], maxCorner: [0, 0, 0] }, + sceneBounds: { minCorner: [0, 0, 0], maxCorner: [0, 0, 0] }, + }; + } + + const table = data.table; + const xCol = table[ColIdx.x]; + const yCol = table[ColIdx.y]; + const zCol = table[ColIdx.z]; + const sxCol = table[ColIdx.sx]; + const syCol = table[ColIdx.sy]; + const szCol = table[ColIdx.sz]; + const qxCol = table[ColIdx.qx]; + const qyCol = table[ColIdx.qy]; + const qzCol = table[ColIdx.qz]; + const qwCol = table[ColIdx.qw]; + const aCol = table[ColIdx.a]; + + const centerBounds: BoundsBox = { + minCorner: [Infinity, Infinity, Infinity], + maxCorner: [-Infinity, -Infinity, -Infinity], + }; + const sceneBounds: BoundsBox = { + minCorner: [Infinity, Infinity, Infinity], + maxCorner: [-Infinity, -Infinity, -Infinity], + }; + + for (let i = 0; i < data.counts; i++) { + const x = xCol[i]; + const y = yCol[i]; + const z = zCol[i]; + const e = extentsFromQuatScale(sxCol[i], syCol[i], szCol[i], qxCol[i], qyCol[i], qzCol[i], qwCol[i], aCol[i]); + const ex = Number.isFinite(e.ex) ? e.ex : 0; + const ey = Number.isFinite(e.ey) ? e.ey : 0; + const ez = Number.isFinite(e.ez) ? e.ez : 0; + + centerBounds.minCorner[0] = Math.min(centerBounds.minCorner[0], x); + centerBounds.minCorner[1] = Math.min(centerBounds.minCorner[1], y); + centerBounds.minCorner[2] = Math.min(centerBounds.minCorner[2], z); + centerBounds.maxCorner[0] = Math.max(centerBounds.maxCorner[0], x); + centerBounds.maxCorner[1] = Math.max(centerBounds.maxCorner[1], y); + centerBounds.maxCorner[2] = Math.max(centerBounds.maxCorner[2], z); + + sceneBounds.minCorner[0] = Math.min(sceneBounds.minCorner[0], x - ex); + sceneBounds.minCorner[1] = Math.min(sceneBounds.minCorner[1], y - ey); + sceneBounds.minCorner[2] = Math.min(sceneBounds.minCorner[2], z - ez); + sceneBounds.maxCorner[0] = Math.max(sceneBounds.maxCorner[0], x + ex); + sceneBounds.maxCorner[1] = Math.max(sceneBounds.maxCorner[1], y + ey); + sceneBounds.maxCorner[2] = Math.max(sceneBounds.maxCorner[2], z + ez); + } + + return { centerBounds, sceneBounds }; +} + +function getBoundsInflation(centerBounds: BoundsBox, sceneBounds: BoundsBox) { + const centerX = Math.max(0, centerBounds.maxCorner[0] - centerBounds.minCorner[0]); + const centerY = Math.max(0, centerBounds.maxCorner[1] - centerBounds.minCorner[1]); + const centerZ = Math.max(0, centerBounds.maxCorner[2] - centerBounds.minCorner[2]); + const sceneX = Math.max(0, sceneBounds.maxCorner[0] - sceneBounds.minCorner[0]); + const sceneY = Math.max(0, sceneBounds.maxCorner[1] - sceneBounds.minCorner[1]); + const sceneZ = Math.max(0, sceneBounds.maxCorner[2] - sceneBounds.minCorner[2]); + const axisInflation = Math.max( + sceneX / Math.max(centerX, 1e-6), + sceneY / Math.max(centerY, 1e-6), + sceneZ / Math.max(centerZ, 1e-6), + ); + const sceneVolume = sceneX * sceneY * sceneZ; + const centerVolume = centerX * centerY * centerZ; + const volumeInflation = sceneVolume / Math.max(centerVolume, 1e-6); + return { axisInflation, volumeInflation }; +} + +function resolveAutoDenseBoxOptions(autoDenseBox?: AutoDenseBoxConfig): Required | null { + if (!autoDenseBox) { + return null; + } + const options = autoDenseBox === true ? {} : autoDenseBox; + const targetCenterKeepRatio = options.targetCenterKeepRatio ?? DEFAULT_AUTO_DENSE_BOX_TARGET_CENTER_KEEP_RATIO; + const minimumCenterKeepRatio = options.minimumCenterKeepRatio ?? DEFAULT_AUTO_DENSE_BOX_MINIMUM_CENTER_KEEP_RATIO; + const minimumAxisInflation = options.minimumAxisInflation ?? DEFAULT_AUTO_DENSE_BOX_MINIMUM_AXIS_INFLATION; + const minimumVolumeInflation = options.minimumVolumeInflation ?? DEFAULT_AUTO_DENSE_BOX_MINIMUM_VOLUME_INFLATION; + return { + targetCenterKeepRatio, + minimumCenterKeepRatio, + minimumAxisInflation, + minimumVolumeInflation, + }; +} + +function resolveVoxelBox(data: SplatData, baseBox: BoundsBox, autoDenseBox?: AutoDenseBoxConfig): BoundsBox { + const autoDenseBoxOptions = resolveAutoDenseBoxOptions(autoDenseBox); + if (!autoDenseBoxOptions || data.counts === 0) { + return baseBox; + } + + const { centerBounds, sceneBounds } = computeCenterAndSceneBounds(data); + const { axisInflation, volumeInflation } = getBoundsInflation(centerBounds, sceneBounds); + const shouldTryDenseBox = + axisInflation >= autoDenseBoxOptions.minimumAxisInflation || + volumeInflation >= autoDenseBoxOptions.minimumVolumeInflation; + if (!shouldTryDenseBox) { + logger.info( + `voxel autoDenseBox skipped: bounds inflation is small ` + + `(axis=${axisInflation.toFixed(2)}x, volume=${volumeInflation.toFixed(2)}x)`, + ); + return baseBox; + } + + const denseBoxTrimRatio = 1 - autoDenseBoxOptions.targetCenterKeepRatio; + const denseBox = toBoundsBox(computeDenseBox(data, denseBoxTrimRatio)); + const clippedDenseBox = intersectBoundsBoxes(baseBox, denseBox); + if (!clippedDenseBox) { + logger.warn( + `voxel autoDenseBox skipped: dense box ${formatBoundsBox(denseBox)} does not overlap base box ${formatBoundsBox(baseBox)}`, + ); + return baseBox; + } + + const centerKeepCount = countCentersInsideBounds(data, clippedDenseBox); + const centerKeepRatio = centerKeepCount / data.counts; + if (centerKeepRatio < autoDenseBoxOptions.minimumCenterKeepRatio) { + logger.warn( + `voxel autoDenseBox skipped: dense box keeps ${centerKeepCount}/${data.counts} centers ` + + `(${(centerKeepRatio * 100).toFixed(1)}%), below minimum ` + + `${(autoDenseBoxOptions.minimumCenterKeepRatio * 100).toFixed(1)}%`, + ); + return baseBox; + } + + logger.info( + `voxel autoDenseBox applied: bounds inflation axis=${axisInflation.toFixed(2)}x, ` + + `volume=${volumeInflation.toFixed(2)}x; center keep=${centerKeepCount}/${data.counts} ` + + `(${(centerKeepRatio * 100).toFixed(1)}%); box=${formatBoundsBox(clippedDenseBox)}`, + ); + return clippedDenseBox; +} + +function compressVoxelBinary(binary: Uint8Array, compression: VoxelBinaryCompression): VoxelBinaryOutput { + switch (compression) { + case 'none': + return { filename: 'voxel.bin', payload: binary }; + case 'gzip': + return { filename: 'voxel.bin.gz', payload: gzipSync(binary) }; + case 'zstd': + return { + filename: 'voxel.bin.zst', + payload: zstdCompressSync(binary, { + params: { + [zlibConstants.ZSTD_c_compressionLevel]: ZSTD_COMPRESSION_LEVEL, + }, + }), + }; + } +} + +function formatCompressedSizePercent(rawBytes: number, compressedBytes: number) { + return rawBytes > 0 ? ((compressedBytes / rawBytes) * 100).toFixed(1) : 'n/a'; +} + +/** + * Build a sparse voxel octree from gaussian splat data. + * + * Pipeline (based on https://github.com/playcanvas/splat-transform/blob/8f3b843efdc378f97d4f6a66a3a90a2de6d479a4/src/lib/writers/write-voxel.ts): + * 1) Compute gaussian extents and scene bounds (`extentsFromQuatScale`). + * 2) Align grid bounds. + * 3) Voxelize to `BlockMaskBuffer` via `gpuVoxelize`. + * 4) Post-process occupancy (`filterAndFillBlocks`, optional `fillExterior`, `fillFloor`, `carve`). + * 5) Crop bounds (`cropToNavigable` or `cropToOccupied`), then build octree (`buildSparseOctree`). + * 6) Optionally generate collision mesh (`buildCollisionMesh` → `collision.glb` next to `voxel.bin`). + * + * @param data - Gaussian splat source data. + * @param voxelResolution - Voxel size in world units. + * @param opacityCutoff - Opacity threshold used during voxelization. + * @param collisionMesh - Whether to generate collision GLB. + * @param navExteriorRadius - Exterior fill radius; requires `navSeed`. + * @param floorFill - Whether to run floor fill before carve. + * @param floorFillDilation - Horizontal dilation radius for floor fill. + * @param box - Axis-aligned world-space clamp box for voxelization. + * @param navCapsule - Capsule config used by `carve`. + * @param navSeed - Seed position used by exterior/carve flood fills. + */ +async function writeVoxels( + data: SplatData, + voxelResolution = 0.05, + opacityCutoff = 0.1, + backend: VoxelBackend = 'gpu', + collisionMesh: CollisionMeshOption = false, + navExteriorRadius?: number, + floorFill = false, + floorFillDilation = 0, + cpuWorkerCount = -1, + box: BoundsBox = { minCorner: [-100, -100, -100], maxCorner: [100, 100, 100] }, + navCapsule?: { height: number; radius: number }, + navSeed?: NavSeed, + nodeEncoding: VoxelNodeEncoding = 'raw', + compression: VoxelBinaryCompression = 'none', + requestedVoxelResolution = voxelResolution, +): Promise { + const hasNav = !!(navCapsule && navSeed && navCapsule.height > 0); + const hasFillExterior = !!(navExteriorRadius && navSeed); + const hasFloorFill = floorFill; + logger.info( + `voxel params: resolution=${voxelResolution}, opacityCutoff=${opacityCutoff}, backend=${backend}, ` + + `collisionMesh=${collisionMesh}, navExteriorRadius=${navExteriorRadius ?? 'none'}, ` + + `floorFill=${floorFill}, floorFillDilation=${floorFillDilation}, cpuWorkerCount=${cpuWorkerCount === -1 ? 'auto' : cpuWorkerCount}, ` + + `box=${JSON.stringify(box)}, navCapsule=${navCapsule ? JSON.stringify(navCapsule) : 'none'}, ` + + `navSeed=${navSeed ? JSON.stringify(navSeed) : 'none'}, hasFillExterior=${hasFillExterior}, hasFloorFill=${hasFloorFill}, hasNav=${hasNav}`, + ); + const xCol = data.table[ColIdx.x]; + const yCol = data.table[ColIdx.y]; + const zCol = data.table[ColIdx.z]; + const sxCol = data.table[ColIdx.sx]; + const syCol = data.table[ColIdx.sy]; + const szCol = data.table[ColIdx.sz]; + const qxCol = data.table[ColIdx.qx]; + const qyCol = data.table[ColIdx.qy]; + const qzCol = data.table[ColIdx.qz]; + const qwCol = data.table[ColIdx.qw]; + const aCol = data.table[ColIdx.a]; + + const sceneBounds: Bounds = { + min: { x: Infinity, y: Infinity, z: Infinity }, + max: { x: -Infinity, y: -Infinity, z: -Infinity }, + }; + + // Compute per-gaussian AABB extents from quaternion+scale and accumulate scene bounds. + logger.time('Voxel bounding/extents'); + const extents = new Float32Array(data.counts * 3); + const extentOpacityThreshold = ALPHA_THRESHOLD; + let invalidExtentCount = 0; + for (let i = 0; i < data.counts; i++) { + const e = extentsFromQuatScale( + sxCol[i], + syCol[i], + szCol[i], + qxCol[i], + qyCol[i], + qzCol[i], + qwCol[i], + aCol[i], + extentOpacityThreshold, + ); + if (!Number.isFinite(e.ex) || !Number.isFinite(e.ey) || !Number.isFinite(e.ez)) { + extents[i * 3 + 0] = 0; + extents[i * 3 + 1] = 0; + extents[i * 3 + 2] = 0; + invalidExtentCount++; + } else { + extents[i * 3 + 0] = e.ex; + extents[i * 3 + 1] = e.ey; + extents[i * 3 + 2] = e.ez; + sceneBounds.min.x = Math.min(sceneBounds.min.x, xCol[i] - e.ex); + sceneBounds.min.y = Math.min(sceneBounds.min.y, yCol[i] - e.ey); + sceneBounds.min.z = Math.min(sceneBounds.min.z, zCol[i] - e.ez); + sceneBounds.max.x = Math.max(sceneBounds.max.x, xCol[i] + e.ex); + sceneBounds.max.y = Math.max(sceneBounds.max.y, yCol[i] + e.ey); + sceneBounds.max.z = Math.max(sceneBounds.max.z, zCol[i] + e.ez); + } + } + if (invalidExtentCount > 0) { + logger.info(`voxel: skipped ${invalidExtentCount} gaussians with invalid extent values`); + } + logger.info( + `scene extents: (${sceneBounds.min.x.toFixed(2)},${sceneBounds.min.y.toFixed(2)},${sceneBounds.min.z.toFixed(2)}) - (${sceneBounds.max.x.toFixed(2)},${sceneBounds.max.y.toFixed(2)},${sceneBounds.max.z.toFixed(2)})`, + ); + logger.timeEnd('Voxel bounding/extents'); + + function resolveVoxelBounds(resolution: number) { + const exteriorPad = hasFillExterior ? (Math.ceil(navExteriorRadius! / resolution) + 1) * resolution : 0; + const floorPad = hasFloorFill ? (Math.ceil(floorFillDilation / resolution) + 1) * resolution : 0; + const padXZ = Math.max(exteriorPad, floorPad); + const padY = exteriorPad; + const rawVoxelBounds: Bounds = { + min: { + x: sceneBounds.min.x - padXZ, + y: sceneBounds.min.y - padY, + z: sceneBounds.min.z - padXZ, + }, + max: { + x: sceneBounds.max.x + padXZ, + y: sceneBounds.max.y + padY, + z: sceneBounds.max.z + padXZ, + }, + }; + const voxelBounds: Bounds = { + min: { + x: Math.max(rawVoxelBounds.min.x, box.minCorner[0]), + y: Math.max(rawVoxelBounds.min.y, box.minCorner[1]), + z: Math.max(rawVoxelBounds.min.z, box.minCorner[2]), + }, + max: { + x: Math.min(rawVoxelBounds.max.x, box.maxCorner[0]), + y: Math.min(rawVoxelBounds.max.y, box.maxCorner[1]), + z: Math.min(rawVoxelBounds.max.z, box.maxCorner[2]), + }, + }; + return { rawVoxelBounds, voxelBounds, gridBounds: alignGridBounds(voxelBounds, resolution) }; + } + + let bounds = resolveVoxelBounds(voxelResolution); + let blockCount = blockCountFromBounds(bounds.gridBounds, voxelResolution); + for (let attempt = 0; blockCount.total > GRID_BLOCK_FALLBACK_TARGET; attempt++) { + if (attempt >= MAX_RESOLUTION_FALLBACK_ATTEMPTS) { + break; + } + const nextVoxelResolution = chooseFallbackResolution( + voxelResolution, + blockCount.total, + GRID_BLOCK_FALLBACK_TARGET, + ); + const detail = `grid blocks=${blockCount.x}x${blockCount.y}x${blockCount.z}, total=${blockCount.total}, target=${GRID_BLOCK_FALLBACK_TARGET}`; + logger.info( + `voxel resolution fallback: ${detail}; ` + `resolution ${voxelResolution} -> ${nextVoxelResolution}`, + ); + voxelResolution = nextVoxelResolution; + bounds = resolveVoxelBounds(voxelResolution); + blockCount = blockCountFromBounds(bounds.gridBounds, voxelResolution); + } + + const { rawVoxelBounds, voxelBounds } = bounds; + const gridBounds = bounds.gridBounds; + const boxCropApplied = + voxelBounds.min.x > rawVoxelBounds.min.x || + voxelBounds.min.y > rawVoxelBounds.min.y || + voxelBounds.min.z > rawVoxelBounds.min.z || + voxelBounds.max.x < rawVoxelBounds.max.x || + voxelBounds.max.y < rawVoxelBounds.max.y || + voxelBounds.max.z < rawVoxelBounds.max.z; + if (boxCropApplied) { + logger.info( + `voxel box crop applied: ` + + `raw=(${rawVoxelBounds.min.x.toFixed(2)},${rawVoxelBounds.min.y.toFixed(2)},${rawVoxelBounds.min.z.toFixed(2)})-` + + `(${rawVoxelBounds.max.x.toFixed(2)},${rawVoxelBounds.max.y.toFixed(2)},${rawVoxelBounds.max.z.toFixed(2)}), ` + + `cropped=(${voxelBounds.min.x.toFixed(2)},${voxelBounds.min.y.toFixed(2)},${voxelBounds.min.z.toFixed(2)})-` + + `(${voxelBounds.max.x.toFixed(2)},${voxelBounds.max.y.toFixed(2)},${voxelBounds.max.z.toFixed(2)})`, + ); + } + if ( + voxelBounds.min.x >= voxelBounds.max.x || + voxelBounds.min.y >= voxelBounds.max.y || + voxelBounds.min.z >= voxelBounds.max.z + ) { + throw new Error(`voxel box does not overlap scene bounds: box=${JSON.stringify(box)}`); + } + if (voxelResolution !== requestedVoxelResolution) { + logger.info(`voxel effective resolution=${voxelResolution} (requested=${requestedVoxelResolution})`); + } + checkVoxelGridCapacity(gridBounds, voxelResolution); + + let blocks: BlockMaskBuffer | undefined; + if (backend === 'gpu') { + const gpuStart = Date.now(); + try { + blocks = await gpuVoxelize( + xCol, + yCol, + zCol, + sxCol, + syCol, + szCol, + qxCol, + qyCol, + qzCol, + qwCol, + aCol, + extents, + gridBounds, + voxelResolution, + opacityCutoff, + ); + const gpuElapsed = Date.now() - gpuStart; + logger.info(`Voxelizing (GPU) done: ${(gpuElapsed / 1000).toFixed(3)}s`); + } catch (e) { + const gpuElapsed = Date.now() - gpuStart; + logger.error(`Voxelizing (GPU) failed after ${(gpuElapsed / 1000).toFixed(3)}s`); + if (e instanceof Error) { + logger.error(`GPU error message: ${e.message}`); + if (e.stack) { + logger.error(`GPU error stack: ${e.stack}`); + } + } else { + logger.error(`GPU error: ${String(e)}`); + } + logger.error('Voxel GPU backend failed, fallback to CPU.'); + } + } + if (!blocks || blocks.count === 0) { + logger.time('Voxelizing (CPU)'); + blocks = await cpuVoxelize( + xCol, + yCol, + zCol, + sxCol, + syCol, + szCol, + qxCol, + qyCol, + qzCol, + qwCol, + aCol, + extents, + gridBounds, + voxelResolution, + opacityCutoff, + { workerCount: cpuWorkerCount }, + ); + logger.timeEnd('Voxelizing (CPU)'); + } + // Remove isolated noise voxels and fill tiny holes at block-mask level. + logger.time('Filter blocks'); + const blockSize = 4 * voxelResolution; + const numBlocksX = Math.round((gridBounds.max.x - gridBounds.min.x) / blockSize); + const numBlocksY = Math.round((gridBounds.max.y - gridBounds.min.y) / blockSize); + const numBlocksZ = Math.round((gridBounds.max.z - gridBounds.min.z) / blockSize); + blocks = filterAndFillBlocks(blocks, numBlocksX, numBlocksY, numBlocksZ); + logger.timeEnd('Filter blocks'); + + logger.time('Loading grid'); + let grid = SparseVoxelGrid.fromBuffer(blocks, numBlocksX << 2, numBlocksY << 2, numBlocksZ << 2); + blocks.clear(); + logger.timeEnd('Loading grid'); + + let navGridBounds = gridBounds; + // Optional navigability passes (aligned with reference order): + // fillExterior -> fillFloor -> carve. + if (hasFillExterior) { + logger.time('Fill exterior'); + const fillResult = await fillExterior( + grid, + navGridBounds, + voxelResolution, + navExteriorRadius!, + navSeed!, + backend, + ); + grid = fillResult.grid; + navGridBounds = fillResult.gridBounds; + logger.timeEnd('Fill exterior'); + } + if (hasFloorFill) { + logger.time('Fill floor'); + const floorResult = await fillFloor(grid, navGridBounds, voxelResolution, floorFillDilation, backend); + grid = floorResult.grid; + navGridBounds = floorResult.gridBounds; + logger.timeEnd('Fill floor'); + } + if (hasNav) { + logger.time('Carve nav'); + const navResult = await carve( + grid, + navGridBounds, + voxelResolution, + navCapsule!.height, + navCapsule!.radius, + navSeed!, + backend, + ); + grid = navResult.grid; + navGridBounds = navResult.gridBounds; + logger.timeEnd('Carve nav'); + } + // Crop padded bounds before octree build. + // If navigability passes ran, keep navigable extent; otherwise crop to occupied extent. + logger.time('Crop voxel bounds'); + const finalCrop = + hasFillExterior || hasFloorFill + ? cropToNavigable(grid, navGridBounds, voxelResolution) + : cropToOccupied(grid, navGridBounds, voxelResolution); + grid = finalCrop.grid; + navGridBounds = finalCrop.gridBounds; + logger.timeEnd('Crop voxel bounds'); + + function resolveCollisionMeshShape() { + if (collisionMesh === false || collisionMesh === undefined) { + return null; + } + if (collisionMesh === true) { + return 'smooth' as const; + } + if (collisionMesh === 'smooth' || collisionMesh === 'faces') { + return collisionMesh; + } + throw new Error( + `Invalid collisionMesh value: ${String(collisionMesh)}. Expected true, false, "smooth", or "faces"`, + ); + } + const collisionMeshShape = resolveCollisionMeshShape(); + const collisionGlb = collisionMeshShape + ? buildCollisionMesh(grid, navGridBounds, voxelResolution, collisionMeshShape) + : undefined; + + // BuildSparseOctree emits Laine-Karras nodes + mixed leaf masks. + logger.time('Build octree'); + let octree: SparseOctree; + try { + octree = buildSparseOctree(grid, navGridBounds, sceneBounds, voxelResolution, { consumeGrid: true }); + logger.timeEnd('Build octree'); + } catch (error) { + logger.timeEnd('Build octree'); + if (error instanceof SparseOctree24BitOverflowError) { + error.voxelResolution = voxelResolution; + } + throw error; + } + logger.info( + `octree: depth=${octree.treeDepth}, interior=${octree.numInteriorNodes}, mixed=${octree.numMixedLeaves}`, + ); + const metadata: VoxelMetadata = { + version: '1.2', + gridBounds: { + min: [octree.gridBounds.min.x, octree.gridBounds.min.y, octree.gridBounds.min.z], + max: [octree.gridBounds.max.x, octree.gridBounds.max.y, octree.gridBounds.max.z], + }, + sceneBounds: { + min: [octree.sceneBounds.min.x, octree.sceneBounds.min.y, octree.sceneBounds.min.z], + max: [octree.sceneBounds.max.x, octree.sceneBounds.max.y, octree.sceneBounds.max.z], + }, + voxelResolution: octree.voxelResolution, + leafSize: octree.leafSize, + treeDepth: octree.treeDepth, + numInteriorNodes: octree.numInteriorNodes, + numMixedLeaves: octree.numMixedLeaves, + nodeCount: octree.nodes.length, + leafDataCount: octree.leafData.length, + files: ['voxel.bin'], + nodeEncoding, + compression, + }; + const binary = + nodeEncoding === 'compact' + ? encodeCompactVoxelBinary(octree.nodes, octree.leafData, octree.numInteriorNodes, octree.numMixedLeaves) + : encodeRawVoxelBinary(octree.nodes, octree.leafData); + + return { metadata, binary, collisionGlb }; +} + +export async function writeVoxelFiles( + outputDir: string, + data: SplatData, + options?: { + voxelResolution?: number; + opacityCutoff?: number; + backend?: VoxelBackend; + collisionMesh?: CollisionMeshOption; + navExteriorRadius?: number; + floorFill?: boolean; + floorFillDilation?: number; + cpuWorkerCount?: number; + box?: BoundsBox; + navCapsule?: { height: number; radius: number }; + navSeed?: NavSeed; + compression?: VoxelBinaryCompression; + nodeEncoding?: VoxelNodeEncoding; + filterCluster?: boolean | FilterClusterOptions; + autoDenseBox?: AutoDenseBoxConfig; + }, +) { + const compression = options?.compression ?? 'none'; + const requestedVoxelResolution = options?.voxelResolution ?? 0.05; + const opacityCutoff = options?.opacityCutoff ?? 0.1; + const backend = options?.backend ?? 'gpu'; + const collisionMesh = options?.collisionMesh ?? false; + const floorFill = options?.floorFill ?? false; + const floorFillDilation = options?.floorFillDilation ?? 0; + const cpuWorkerCount = options?.cpuWorkerCount ?? -1; + const baseBox = options?.box ?? DEFAULT_VOXEL_BOX; + const box = resolveVoxelBox(data, baseBox, options?.autoDenseBox ?? true); + const nodeEncoding = options?.nodeEncoding ?? 'raw'; + const filterClusterOptions = options?.filterCluster ?? true; + let sourceData = data; + if (filterClusterOptions) { + const filterOptions = filterClusterOptions === true ? {} : filterClusterOptions; + const filterRuntime: FilterClusterRuntimeOptions = { + backend, + box, + }; + if (options?.cpuWorkerCount !== undefined) { + filterRuntime.cpuWorkerCount = cpuWorkerCount; + } + logger.info('voxel filterCluster enabled'); + sourceData = await filterCluster(data, filterOptions, filterRuntime); + } + let attemptVoxelResolution = requestedVoxelResolution; + let result: VoxelWriteResult | undefined; + for (let attempt = 0; attempt <= MAX_RESOLUTION_FALLBACK_ATTEMPTS; attempt++) { + try { + result = await writeVoxels( + sourceData, + attemptVoxelResolution, + opacityCutoff, + backend, + collisionMesh, + options?.navExteriorRadius, + floorFill, + floorFillDilation, + cpuWorkerCount, + box, + options?.navCapsule, + options?.navSeed, + nodeEncoding, + compression, + requestedVoxelResolution, + ); + break; + } catch (error) { + if (!(error instanceof SparseOctree24BitOverflowError) || attempt >= MAX_RESOLUTION_FALLBACK_ATTEMPTS) { + throw error; + } + const failedResolution = error.voxelResolution ?? attemptVoxelResolution; + const target = Math.min(OCTREE_24BIT_FALLBACK_TARGET, Math.floor(error.limit * 0.98)); + const nextVoxelResolution = chooseFallbackResolution(failedResolution, error.actual, target); + const detail = `octree 24-bit ${error.kind} count=${error.actual}, target=${target}, limit=${error.limit}`; + logger.info( + `voxel resolution fallback: ${detail}; ` + `resolution ${failedResolution} -> ${nextVoxelResolution}`, + ); + attemptVoxelResolution = nextVoxelResolution; + } + } + if (!result) { + throw new Error('voxel resolution fallback exhausted without producing output'); + } + const { metadata, binary, collisionGlb } = result; + fs.mkdirSync(outputDir, { recursive: true }); + const metaPath = path.join(outputDir, 'voxel-meta.json'); + const rawBytes = binary.length; + const compressedBinary = compressVoxelBinary(binary, compression); + const binPayload = compressedBinary.payload; + const binPath = path.join(outputDir, compressedBinary.filename); + metadata.files = [compressedBinary.filename]; + logger.info(`writing '${metaPath}'...`); + fs.writeFileSync(metaPath, Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8')); + logger.info(`writing '${binPath}'...`); + fs.writeFileSync(binPath, binPayload); + if (collisionGlb && collisionGlb.length > 0) { + const glbPath = path.join(outputDir, 'collision.glb'); + logger.info(`writing '${glbPath}'...`); + fs.writeFileSync(glbPath, collisionGlb); + } + function formatSize(bytes: number) { + if (bytes >= 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + return `${(bytes / 1024).toFixed(1)} KB`; + } + const octreeSizeMsg = + compression === 'none' + ? `octree ${formatSize(rawBytes)}` + : `octree raw: ${formatSize(rawBytes)}, ${compression}: ${formatSize(binPayload.length)} (${formatCompressedSizePercent(rawBytes, binPayload.length)}%)`; + if (collisionGlb && collisionGlb.length > 0) { + logger.info(`${octreeSizeMsg}, collision mesh ${formatSize(collisionGlb.length)}`); + } else { + logger.info(octreeSizeMsg); + } +} + +export const voxelUtils = { + getChildOffset, + encodeMorton3, + decodeMorton3, +}; diff --git a/packages/splat-transform/src/index.ts b/packages/splat-transform/src/index.ts new file mode 100644 index 0000000..2492ddf --- /dev/null +++ b/packages/splat-transform/src/index.ts @@ -0,0 +1,80 @@ +import { AutoChunkLodTask } from './tasks/AutoChunkLodTask.js'; +import { AutoLodTask } from './tasks/AutoLodTask.js'; +import { FlexLodTask } from './tasks/FlexLodTask.js'; +import { SkeletonLodTask } from './tasks/SkeletonLodTask.js'; +import { ModifyTask } from './tasks/ModifyTask.js'; +import { ReadTask } from './tasks/ReadTask.js'; +import { WriteTask } from './tasks/WriteTask.js'; +import { VoxelTask } from './tasks/VoxelTask.js'; +import type { BaseTask, Context } from './tasks/BaseTask.js'; +import { enumerateAdapters, logger, releaseSharedDevice, initGPUAdapter } from './utils/index.js'; +import { SplitSplatTask } from './tasks/SplitSplatTask.js'; + +type PipelineTask = { + id: string; + type: string; + config: any; + release?: string[]; +}; + +interface PipelineConfig { + version: number; + gpu?: number; + tasks: PipelineTask[]; +} + +const TaskMap: Record> = { + Read: new ReadTask(), + Write: new WriteTask(), + Voxel: new VoxelTask(), + Modify: new ModifyTask(), + SkeletonLod: new SkeletonLodTask(), + FlexLod: new FlexLodTask(), + AutoLod: new AutoLodTask(), + AutoChunkLod: new AutoChunkLodTask(), + SplitSplatTask: new SplitSplatTask(), +}; + +export function injectCustomTask>(name: string, task: T) { + if (TaskMap[name]) { + logger.warn(`Task: ${name} has been injected before, will override`); + } + TaskMap[name] = task; +} + +function anyTaskRequireGPU(tasks: PipelineTask[]) { + for (const t of tasks) { + if (TaskMap[t.type].requiresGPU(t.config)) { + return true; + } + } + return false; +} + +export async function run(config: PipelineConfig) { + console.time('Total elapsed time'); + const ctx: Context = { + logger, + resources: new Map(), + }; + if (anyTaskRequireGPU(config.tasks)) { + logger.prefix = `[Task:GPU]`; + logger.info('Any task requires GPU detected, initialize GPU adapter.'); + const adapter = (await enumerateAdapters())[config.gpu ?? 0]; + initGPUAdapter([`adapter=${adapter.name}`]); + } + for (const taskDef of config.tasks) { + const { id, type, config: taskConfig, release = [] } = taskDef; + const task = TaskMap[type]; + if (!task) { + throw new Error(`Task not found: ${type} (id: ${id})`); + } + logger.prefix = `[Task:${type}#${id}]`; + logger.time('elapsed time'); + await task.exec(taskConfig, ctx); + release.forEach(v => ctx.resources.delete(v)); + logger.timeEnd('elapsed time'); + } + releaseSharedDevice(); + console.timeEnd('Total elapsed time'); +} diff --git a/packages/splat-transform/src/native/index.ts b/packages/splat-transform/src/native/index.ts new file mode 100644 index 0000000..ac194ee --- /dev/null +++ b/packages/splat-transform/src/native/index.ts @@ -0,0 +1,352 @@ +import { createRequire } from 'node:module'; +import { SplatData } from '../SplatData.js'; +import { Buffer } from 'node:buffer'; +import { getNativePackageName } from './utils.js'; + +declare namespace NativeModule { + interface SplatLodResult { + /** + * block bounding box, blocks. + */ + blockBoxes: Buffer; + /** + * size, blocks * levels, splat reference index of block's level, level orders. + * eg. [b0_l0, b0_l1, b0_l2, ....] + */ + blockRefs: Buffer; + /** + * each splat gaussian count. + */ + gaussianCount: Buffer; + /** + * splat data table + */ + data: Float32Array[]; + } + + interface SplatSplitResult { + /** + * block bounding box, blocks. + */ + blockBoxes: Buffer; + /** + * each splat gaussian count. + */ + gaussianCount: Buffer; + /** + * splat data table + */ + data: Float32Array[]; + } + + class ThreadPool { + readonly threadCount: number; + readonly taskCount: number; + constructor(threadCount?: number); + } + export interface Module { + ThreadPool: typeof ThreadPool; + generate_splat_lod( + data: Buffer[], + shSize: number, + parameters: Buffer, + blockPrecision: number, + minSize: number, + maxStep: number, + threadPool: ThreadPool, + ): SplatLodResult; + split_splat(data: Buffer[], shSize: number, blockPrecision: number, threadPool: ThreadPool): SplatSplitResult; + webp_encode_rgba(color: Buffer, width: number, height: number, quality: number): Buffer; + webp_encode_rgba_lossless(color: Buffer, width: number, height: number): Buffer; + webp_decode_rgba(data: Buffer): { + data: Buffer; + width: number; + height: number; + }; + avif_encode_rgba(color: Buffer, width: number, height: number, quality: number): Buffer; + avif_encode_rgba_batched( + data: Array<{ data: Buffer; width: number; height: number; quality: number }>, + threadPool: ThreadPool, + ): Buffer[]; + avif_decode_rgba(data: Buffer): { + data: Buffer; + width: number; + height: number; + }; + avif_decode_rgba_batched( + data: Buffer[], + threadPool: ThreadPool, + ): Array<{ + data: Buffer; + width: number; + height: number; + }>; + cluster_average( + data_table: Buffer[], + labels: Buffer, + k: number, + output: Buffer[], + threadPool: ThreadPool, + ): void; + } +} + +const getModule = (function () { + let m: NativeModule.Module | undefined = undefined; + const require = createRequire(import.meta.url); + const binaryModule = getNativePackageName() + '/splat-transform.node'; + + return function () { + if (!m) { + m = require(binaryModule); + } + return m!; + }; +})(); + +const [defaultThreadPool, smallThreadPool] = (function () { + let defaultTheadPool: NativeModule.ThreadPool | undefined; + let smallTheadPool: NativeModule.ThreadPool | undefined; + return [ + function () { + if (!defaultTheadPool) { + defaultTheadPool = new (getModule().ThreadPool)(); + } + return defaultTheadPool; + }, + function () { + if (!smallTheadPool) { + smallTheadPool = new (getModule().ThreadPool)(4); + } + return smallTheadPool; + }, + ]; +})(); + +export interface LevelParameter { + precision: number; + scaleBoost: number; +} + +export interface BlockedSplats { + box: { + min: [number, number, number]; + max: [number, number, number]; + }; + /** + * current block referenced splats, level ordered. + */ + refs: number[]; +} + +export interface BlockedResult { + splats: SplatData[]; + blocks: BlockedSplats[]; +} + +export function generateSplatLod( + splat: SplatData, + levelParameters: LevelParameter[], + blockPrecision: number, + minSize: number, + maxStep: number, +): BlockedResult { + if (splat.counts === 0) { + return { + splats: [splat], + blocks: [ + { + box: { min: [0, 0, 0], max: [0, 0, 0] }, + refs: new Array(levelParameters.length).fill(0), + }, + ], + }; + } + const levels = levelParameters.length; + const inputBuffers = splat.table.map(b => Buffer.from(b.buffer, b.byteOffset, b.byteLength)); + const buffer = Buffer.alloc(levels * 8); + { + const parameters = new Float32Array(buffer.buffer, buffer.byteOffset, levels * 2); + for (let i = 0; i < levelParameters.length; i++) { + const { precision, scaleBoost } = levelParameters[i]; + parameters[i * 2] = precision; + parameters[i * 2 + 1] = scaleBoost; + } + } + const { blockBoxes, blockRefs, gaussianCount, data } = getModule().generate_splat_lod( + inputBuffers, + splat.shCounts, + buffer, + blockPrecision, + minSize, + maxStep, + defaultThreadPool(), + ); + const blockView = new Float32Array(blockBoxes.buffer, blockBoxes.byteOffset, blockBoxes.byteLength / 4); + const blockRefsView = new Uint32Array(blockRefs.buffer, blockRefs.byteOffset, blockRefs.byteLength / 4); + const blockCount = blockView.length / 6; + const gaussianCountView = new Uint32Array( + gaussianCount.buffer, + gaussianCount.byteOffset, + gaussianCount.byteLength / 4, + ); + const blocks: BlockedSplats[] = []; + const splats: SplatData[] = []; + + // read splats + { + for (let i = 0; i < gaussianCountView.length; i++) { + const count = gaussianCountView[i]; + const splatData = new SplatData(1, splat.shDegree); + splatData.shDegree = splat.shDegree; + splatData.shCounts = splat.shCounts; + splatData.counts = count; + splatData.table = data.slice(i * splat.table.length, i * splat.table.length + splat.table.length); + splats.push(splatData); + } + } + + for (let i = 0; i < blockCount; i++) { + const block: BlockedSplats = { + box: { + min: [blockView[i * 6], blockView[i * 6 + 1], blockView[i * 6 + 2]], + max: [blockView[i * 6 + 3], blockView[i * 6 + 4], blockView[i * 6 + 5]], + }, + refs: Array.from(blockRefsView.subarray(i * levels, i * levels + levels)), + }; + blocks.push(block); + } + + return { splats, blocks }; +} + +export function splitSplat(splat: SplatData, blockPrecision: number): BlockedResult { + if (splat.counts === 0) { + return { + splats: [splat], + blocks: [ + { + box: { min: [0, 0, 0], max: [0, 0, 0] }, + refs: [0], + }, + ], + }; + } + const inputBuffers = splat.table.map(b => Buffer.from(b.buffer, b.byteOffset, b.byteLength)); + const { blockBoxes, gaussianCount, data } = getModule().split_splat( + inputBuffers, + splat.shCounts, + blockPrecision, + defaultThreadPool(), + ); + const blocks: BlockedSplats[] = []; + const splats: SplatData[] = []; + const blockView = new Float32Array(blockBoxes.buffer, blockBoxes.byteOffset, blockBoxes.byteLength / 4); + const gaussianCountView = new Uint32Array( + gaussianCount.buffer, + gaussianCount.byteOffset, + gaussianCount.byteLength / 4, + ); + const blockCount = blockView.length / 6; + + // read splats + { + for (let i = 0; i < gaussianCountView.length; i++) { + const count = gaussianCountView[i]; + const splatData = new SplatData(1, splat.shDegree); + splatData.shDegree = splat.shDegree; + splatData.shCounts = splat.shCounts; + splatData.counts = count; + splatData.table = data.slice(i * splat.table.length, i * splat.table.length + splat.table.length); + splats.push(splatData); + } + } + + for (let i = 0; i < blockCount; i++) { + const block: BlockedSplats = { + box: { + min: [blockView[i * 6], blockView[i * 6 + 1], blockView[i * 6 + 2]], + max: [blockView[i * 6 + 3], blockView[i * 6 + 4], blockView[i * 6 + 5]], + }, + refs: [i], + }; + blocks.push(block); + } + + return { + splats, + blocks, + }; +} + +export class WebPLosslessProfile { + readonly lossless = true; +} + +export class WebPQualityProfile { + readonly lossless = false; + constructor(readonly quality: number) {} +} + +export function encodeWebP( + data: Uint8Array | Buffer, + width: number, + height: number, + profile: WebPLosslessProfile | WebPQualityProfile, +) { + const buffer = data instanceof Buffer ? data : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + if (profile.lossless) { + return getModule().webp_encode_rgba_lossless(buffer, width, height); + } else { + return getModule().webp_encode_rgba(buffer, width, height, profile.quality); + } +} + +export function decodeWebP(data: Uint8Array | Buffer) { + const buffer = data instanceof Buffer ? data : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + return getModule().webp_decode_rgba(buffer); +} + +export function encodeAVIF(data: Uint8Array | Buffer, width: number, height: number, quality: number) { + const buffer = data instanceof Buffer ? data : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + return getModule().avif_encode_rgba(buffer, width, height, quality); +} + +export interface AVIFEncodeInput { + data: Uint8Array | Buffer; + width: number; + height: number; + quality: number; +} + +export function encodeAVIFBatched(inputs: AVIFEncodeInput[]) { + return getModule().avif_encode_rgba_batched( + inputs.map(i => ({ + ...i, + data: i.data instanceof Buffer ? i.data : Buffer.from(i.data.buffer, i.data.byteOffset, i.data.byteLength), + })), + smallThreadPool(), + ); +} + +export function decodeAVIF(data: Uint8Array | Buffer) { + const buffer = data instanceof Buffer ? data : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + return getModule().avif_decode_rgba(buffer); +} + +export function decodeAVIFBatched(inputs: Array) { + return getModule().avif_decode_rgba_batched( + inputs.map(i => (i instanceof Buffer ? i : Buffer.from(i.buffer, i.byteOffset, i.byteLength))), + smallThreadPool(), + ); +} + +export function clusterAverage(dataTable: Float32Array[], labels: Uint32Array, k: number, output: Float32Array[]) { + return getModule().cluster_average( + dataTable.map(t => Buffer.from(t.buffer, t.byteOffset, t.byteLength)), + Buffer.from(labels.buffer, labels.byteOffset, labels.byteLength), + k, + output.map(t => Buffer.from(t.buffer, t.byteOffset, t.byteLength)), + defaultThreadPool(), + ); +} diff --git a/packages/splat-transform/src/native/utils.ts b/packages/splat-transform/src/native/utils.ts new file mode 100644 index 0000000..eacfe2b --- /dev/null +++ b/packages/splat-transform/src/native/utils.ts @@ -0,0 +1,68 @@ +import { readFileSync } from 'node:fs'; +import child_process from 'node:child_process'; +import p from '../../package.json' with { type: 'json' }; + +function isMusl() { + let musl: boolean | null = false; + if (process.platform === 'linux') { + musl = isMuslFromFilesystem(); + if (musl == null) { + musl = isMuslFromReport(); + } + if (musl == null) { + musl = isMuslFromChildProcess(); + } + } + return musl; +} + +function isFileMusl(f: string) { + return f.includes('libc.musl-') || f.includes('ld-musl-'); +} + +function isMuslFromFilesystem() { + try { + return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl'); + } catch { + return null; + } +} + +function isMuslFromReport() { + let report: any = null; + if (typeof process.report?.getReport === 'function') { + (process.report as any).excludeNetwork = true; + report = process.report.getReport(); + } + if (!report) { + return null; + } + if (report.header && report.header.glibcVersionRuntime) { + return false; + } + if (Array.isArray(report.sharedObjects)) { + if (report.sharedObjects.some(isFileMusl)) { + return true; + } + } + return false; +} + +function isMuslFromChildProcess() { + try { + return child_process.execSync('ldd --version', { encoding: 'utf8' }).includes('musl'); + } catch { + // If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false + return false; + } +} + +export function getNativePackageName() { + let runtime: string | undefined = undefined; + if (process.platform === 'win32') { + runtime = 'msvc'; + } else if (process.platform === 'linux') { + runtime = isMusl() ? 'musl' : 'gnu'; + } + return `${p.name}-${process.platform}-${process.arch}${runtime ? `-${runtime}` : ''}`; +} diff --git a/packages/splat-transform/src/tasks/AutoChunkLodTask.ts b/packages/splat-transform/src/tasks/AutoChunkLodTask.ts new file mode 100644 index 0000000..77ea56f --- /dev/null +++ b/packages/splat-transform/src/tasks/AutoChunkLodTask.ts @@ -0,0 +1,156 @@ +import type { SplatData } from '../SplatData.js'; +import { combineSplatData, computeDenseBox } from '../utils/index.js'; +import { type Context, BaseTask, type SingleFile } from './BaseTask.js'; +import { generateSplatLod, type LevelParameter } from '../native/index.js'; + +export interface Config { + input: string; + output: string; + type: string; + maxChunkCounts?: number; + levels?: LevelParameter[]; +} + +interface OutputBlock { + bound: { + max: [number, number, number]; + min: [number, number, number]; + }; + lods: Array<{ + file: number; + offset: number; + count: number; + }>; +} + +const DefaultLevels: LevelParameter[] = [ + { precision: 1.0, scaleBoost: 1 }, + { precision: 0.5, scaleBoost: 1 }, + { precision: 0.25, scaleBoost: 1 }, + { precision: 0.05, scaleBoost: 1.01 }, + { precision: 0.01, scaleBoost: 1.02 }, +]; + +export class AutoChunkLodTask extends BaseTask { + override async exec(config: Config, { logger, resources }: Context) { + const { input, output, type, maxChunkCounts = 400000, levels = DefaultLevels } = config; + const inputData = resources.get(input); + // TODO: array support... + const splat = Array.isArray(inputData) ? (inputData[0].content as SplatData) : (inputData as SplatData); + logger.info(`loaded -> "${input}"`); + const forwardBox = computeDenseBox(splat, 0.8); + + const outputs: SingleFile[] = []; + const outputBlocks: OutputBlock[] = []; + const permanentFiles: number[] = []; + { + logger.info('generate lod'); + logger.time('generate elapsed'); + const { blocks, splats } = generateSplatLod( + splat, + levels, + Math.min(1, maxChunkCounts / splat.counts), + 2000, + 20, + ); + logger.timeEnd('generate elapsed'); + + const chunkL3Idx: number[] = []; + const chunkL4Idx: number[] = []; + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + chunkL4Idx.push(block.refs[4]); + if (block.refs[3] !== block.refs[4]) { + chunkL3Idx.push(block.refs[3]); + } + } + const layout = new Map(); + { + const chunkL4 = combineSplatData(chunkL4Idx.map(idx => splats[idx])); + outputs.push({ name: `chunk_0.${type}`, content: chunkL4, preserveOrder: true }); + permanentFiles.push(0); + let offset = 0; + for (let i = 0; i < chunkL4Idx.length; i++) { + const idx = chunkL4Idx[i]; + const counts = splats[idx].counts; + layout.set(idx, { idx: 0, offset, counts }); + offset += counts; + } + } + if (chunkL3Idx.length > 0) { + const chunkL3 = combineSplatData(chunkL3Idx.map(idx => splats[idx])); + outputs.push({ name: `chunk_1.${type}`, content: chunkL3, preserveOrder: true }); + permanentFiles.push(1); + let offset = 0; + for (let i = 0; i < chunkL3Idx.length; i++) { + const idx = chunkL3Idx[i]; + const counts = splats[idx].counts; + layout.set(idx, { idx: 1, offset, counts }); + offset += counts; + } + } + + for (let i = 0; i < splats.length; i++) { + if (chunkL3Idx.includes(i) || chunkL4Idx.includes(i)) { + continue; + } + const idx = outputs.length; + const splat = splats[i]; + outputs.push({ + name: `chunk_${idx}.${type}`, + content: splat, + }); + layout.set(i, { idx, offset: 0, counts: splat.counts }); + } + for (const block of blocks) { + outputBlocks.push({ + bound: block.box, + lods: block.refs.map(ref => { + const v = layout.get(ref)!; + return { + file: v.idx, + offset: v.offset, + count: v.counts, + }; + }), + }); + } + } + + logger.info(`Total blocks: ${outputBlocks.length}, files: ${outputs.length}`); + logger.info(`Gaussian per level: `); + let maxLength = 0; + for (let i = 0; i < levels.length; i++) { + const level = levels[i]; + const levelCount = outputBlocks.map(block => block.lods[i].count).reduce((acc, i) => acc + i, 0); + const levelStr = levelCount.toString().padStart(maxLength, ' '); + maxLength = levelStr.length; + logger.info( + `\tLevel ${i}${`(${(level.precision * 100).toFixed(2)}%)`.padStart(9, ' ')}: ${levelStr}${`(${((levelCount / splat.counts) * 100).toFixed(2)}%)`.padStart(9, ' ')}`, + ); + } + + resources.set(output, [ + { + name: 'lod-meta.json', + content: JSON.stringify({ + magicCode: 0x262834, + type: 'lod-splat', + version: '1.0', + counts: splat.counts, + shDegree: splat.shDegree, + levels: levels.length, + forwardBox, + files: outputs.map(f => f.name), + permanentFiles, + tree: outputBlocks, + }), + }, + ...outputs, + ]); + } + + override requiresGPU(config: Config): boolean { + return config.type === 'sog'; + } +} diff --git a/packages/splat-transform/src/tasks/AutoLodTask.ts b/packages/splat-transform/src/tasks/AutoLodTask.ts new file mode 100644 index 0000000..1c2da9f --- /dev/null +++ b/packages/splat-transform/src/tasks/AutoLodTask.ts @@ -0,0 +1,39 @@ +import type { SplatData } from '../SplatData.js'; +import { combineSplatData } from '../utils/index.js'; +import { generateSplatLod } from '../native/index.js'; +import { type Context, BaseTask } from './BaseTask.js'; + +export interface Config { + input: string; + output: string; + counts?: number; + ratio?: number; +} + +export class AutoLodTask extends BaseTask { + override async exec(config: Config, { logger, resources }: Context) { + const { input, output, counts = Infinity, ratio = 0.3 } = config; + const inputData = resources.get(input); + // TODO: array support... + const splat = Array.isArray(inputData) ? (inputData[0].content as SplatData) : (inputData as SplatData); + logger.info(`loaded -> "${input}"`); + const target = Math.min(Math.ceil(splat.counts * ratio), counts); + logger.info( + `expected -> ${target}(${((target / splat.counts) * 100).toFixed(2)}%) | ratio=${ratio} counts=${counts}`, + ); + const { blocks, splats } = generateSplatLod( + splat, + [ + { precision: 1.0, scaleBoost: 1.0 }, + { precision: target / splat.counts, scaleBoost: 1.0 }, + ], + 0.2, + 2000, + 20, + ); + const raw = combineSplatData(blocks.map(item => splats[item.refs[1]])); + logger.info(`result -> ${raw.counts}(${((raw.counts / target) * 100).toFixed(2)}%)`); + resources.set(output, raw); + logger.info(`stored -> key="${output}"`); + } +} diff --git a/packages/splat-transform/src/tasks/BaseTask.ts b/packages/splat-transform/src/tasks/BaseTask.ts new file mode 100644 index 0000000..0d7701a --- /dev/null +++ b/packages/splat-transform/src/tasks/BaseTask.ts @@ -0,0 +1,21 @@ +import type { SplatData } from '../SplatData.js'; +import type { Logger } from '../utils/Logger.js'; + +export interface SingleFile { + name: string; + content: SplatData | string; + preserveOrder?: boolean; +} + +export interface Context { + logger: Logger; + resources: Map; +} + +export abstract class BaseTask { + abstract exec(config: T, ctx: Context): Promise; + + requiresGPU(_config: T) { + return false; + } +} diff --git a/packages/splat-transform/src/tasks/FlexLodTask.ts b/packages/splat-transform/src/tasks/FlexLodTask.ts new file mode 100644 index 0000000..3136fe8 --- /dev/null +++ b/packages/splat-transform/src/tasks/FlexLodTask.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs'; +import { type ISingleSplat, SplatData } from '../SplatData.js'; +import { type Context, BaseTask } from './BaseTask.js'; + +export interface Config { + input: string; + output: string; + scorePath: string; + counts?: number; + ratio?: number; + originalIndices?: string; +} + +export class FlexLodTask extends BaseTask { + override async exec(config: Config, { logger, resources }: Context) { + const { input, output, scorePath, counts = Infinity, ratio = 0.3, originalIndices } = config; + const splat = resources.get(input) as SplatData; + logger.info(`loaded -> "${input}"`); + const target = Math.min(counts, Math.ceil(splat.counts * ratio)); + logger.info( + `expected -> ${target}(${((target / splat.counts) * 100).toFixed(2)}%) | ratio=${ratio} counts=${counts}`, + ); + + const scores = new Float32Array(fs.readFileSync(scorePath).buffer); + let sorted = new Uint32Array(splat.counts); + for (let i = 0; i < sorted.length; i++) { + sorted[i] = i; + } + sorted.sort((a, b) => scores[b] - scores[a]); + sorted = sorted.subarray(0, target).sort((a, b) => a - b); + + const raw = new SplatData().init(target, splat.shDegree); + + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: new Array(splat.shCounts), + }; + const shN = single.shN; + for (let i = 0; i < target; i++) { + splat.get(sorted[i], single); + splat.getShN(sorted[i], shN); + raw.set(i, single); + raw.setShN(i, shN); + } + + if (originalIndices) { + const originIndices = new Uint32Array(target); + for (let i = 0; i < target; i++) { + originIndices[i] = sorted[i]; + } + fs.writeFileSync(originalIndices, originIndices); + logger.info(`original indices saved -> "${originalIndices}"`); + } + + resources.set(output, raw); + logger.info(`stored -> key="${output}"`); + } +} diff --git a/packages/splat-transform/src/tasks/ModifyTask.ts b/packages/splat-transform/src/tasks/ModifyTask.ts new file mode 100644 index 0000000..c8b97bb --- /dev/null +++ b/packages/splat-transform/src/tasks/ModifyTask.ts @@ -0,0 +1,209 @@ +import fs from 'node:fs'; +import type { ISingleSplat, SplatData } from '../SplatData.js'; +import { createSHRotateFn, fastDeleteSplat, Matrix3, Matrix4, Quaternion, Vector3 } from '../utils/index.js'; +import { type Context, BaseTask } from './BaseTask.js'; + +interface ISplatModify { + isRowMatrix: boolean; + transform: number[]; + deletedIndices: number[]; + indicesTransform: Array<{ indices: number[]; transform: number[] }>; +} + +async function createSplatModify(path: string, counts: number) { + if (!path) { + return undefined; + } + const { + isRowMatrix = true, + transform, + deletedIndices: deletedIndicesBitMap = [], + indicesTransform = [], + } = JSON.parse(fs.readFileSync(path, 'utf-8')) as Partial; + + const used = new Uint8Array(counts); + let usedCounts = 0; + const deletedIndices: number[] = []; + for (let i = 0; i < deletedIndicesBitMap.length; i++) { + const v = deletedIndicesBitMap[i]; + for (let j = 0; j < 8; j++) { + if (v & (1 << j)) { + const idx = i * 8 + j; + deletedIndices.push(idx); + used[idx] = 1; + usedCounts++; + } + } + } + + const groupIndices: number[][] = []; + const groupTransforms: Array<{ + isScale: boolean; + isRotate: boolean; + matrix: Matrix4; + scale: Vector3; + quat: Quaternion; + shRotateFn: (shN: number[]) => void; + }> = []; + + const modelMatrix = new Matrix4(transform, isRowMatrix); + const transforms = indicesTransform.map(v => new Matrix4(v.transform, isRowMatrix).multiply(modelMatrix)); + for (let i = 0; i < transforms.length; i++) { + const { indices } = indicesTransform[i]; + for (let j = 0; j < indices.length; j++) { + used[indices[j]] = 1; + } + usedCounts += indices.length; + + const matrix = transforms[i]; + if (matrix.equals(Matrix4.ONE)) { + continue; + } + const scale = new Vector3(1, 1, 1); + const quat = new Quaternion(0, 0, 0, 1); + matrix.decompose(new Vector3(1, 1, 1), quat, scale); + groupIndices.push(indices); + groupTransforms.push({ + isScale: !scale.equals(Vector3.ONE), + isRotate: !quat.equals(Quaternion.ONE), + matrix, + scale, + quat, + shRotateFn: createSHRotateFn( + new Matrix3().setFromMatrix4(new Matrix4().compose(new Vector3(0, 0, 0), quat, new Vector3(1, 1, 1))), + ), + }); + } + + if (!modelMatrix.equals(Matrix4.ONE)) { + const indices = new Array(counts - usedCounts); + for (let i = 0; i < used.length; i++) { + if (used[i]) { + continue; + } + indices.push(i); + } + + const matrix = modelMatrix; + const scale = new Vector3(1, 1, 1); + const quat = new Quaternion(0, 0, 0, 1); + matrix.decompose(new Vector3(1, 1, 1), quat, scale); + groupIndices.unshift(indices); + groupTransforms.unshift({ + isScale: !scale.equals(Vector3.ONE), + isRotate: !quat.equals(Quaternion.ONE), + matrix, + scale, + quat, + shRotateFn: createSHRotateFn( + new Matrix3().setFromMatrix4(new Matrix4().compose(new Vector3(0, 0, 0), quat, new Vector3(1, 1, 1))), + ), + }); + } + + return { + deletedIndices, + groupIndices, + groupTransforms, + }; +} + +export interface Config { + input: string; + output: string; + modifyPaths?: string[]; +} + +export class ModifyTask extends BaseTask { + override async exec(config: Config, { logger, resources }: Context) { + const { input, modifyPaths = [], output } = config; + const splat = resources.get(input) as SplatData; + logger.info(`loaded -> "${input}"`); + const modifies = await Promise.all( + modifyPaths.map((p, i) => createSplatModify(p, splat.blockContentCounts[i])), + ); + const tempVec = new Vector3(0, 0, 0); + const tempQuat = new Quaternion(0, 0, 0, 1); + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: new Array(splat.shCounts), + }; + const shN = single.shN; + const shCoeffs: number[] = new Array(splat.shCounts / 3).fill(0); + const deletedTotalIndices: number[] = []; + for (let i = 0; i < modifies.length; i++) { + const modify = modifies[i]; + if (!modify) { + logger.info(`modify[${i}] is null, skip`); + continue; + } + const offset = splat.blockOffsets[i]; + const { deletedIndices, groupIndices, groupTransforms } = modify; + logger.info(`modify[${i}] offset=${offset} groups=${groupIndices.length} delete=${deletedIndices.length}`); + for (let j = 0; j < groupIndices.length; j++) { + const indices = groupIndices[j]; + const { isScale, isRotate, matrix, scale, quat, shRotateFn } = groupTransforms[j]; + logger.info(`group[${i}:${j}] size=${indices.length} scale=${isScale} rotate=${isRotate}`); + for (let k = 0; k < indices.length; k++) { + const idx = offset + indices[k]; + splat.get(idx, single); + tempVec.set(single.x, single.y, single.z).applyMatrix4(matrix); + single.x = tempVec.x; + single.y = tempVec.y; + single.z = tempVec.z; + if (isScale) { + tempVec.set(single.sx, single.sy, single.sz).mul(scale); + single.sx = tempVec.x; + single.sy = tempVec.y; + single.sz = tempVec.z; + } + if (isRotate) { + tempQuat.set(single.qx, single.qy, single.qz, single.qw).premultiply(quat); + single.qx = tempQuat.x; + single.qy = tempQuat.y; + single.qz = tempQuat.z; + single.qw = tempQuat.w; + } + splat.set(idx, single); + if (isRotate) { + splat.getShN(idx, shN); + for (let m = 0; m < 3; m++) { + for (let n = 0; n < shCoeffs.length; n++) { + shCoeffs[n] = shN[n * 3 + m]; + } + shRotateFn(shCoeffs); + for (let n = 0; n < shCoeffs.length; n++) { + shN[n * 3 + m] = shCoeffs[n]; + } + } + splat.setShN(idx, shN); + } + } + } + + for (let j = 0; j < deletedIndices.length; j++) { + deletedTotalIndices.push(offset + deletedIndices[j]); + } + } + if (deletedTotalIndices.length > 0) { + fastDeleteSplat(splat, deletedTotalIndices); + logger.info(`delete ${deletedTotalIndices.length} splat`); + } + + resources.set(output, splat); + logger.info(`stored -> key="${output}"`); + } +} diff --git a/packages/splat-transform/src/tasks/ReadTask.ts b/packages/splat-transform/src/tasks/ReadTask.ts new file mode 100644 index 0000000..1daaca7 --- /dev/null +++ b/packages/splat-transform/src/tasks/ReadTask.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import { Readable } from 'node:stream'; +import { SplatData } from '../SplatData.js'; +import { createSplatFile } from '../utils/index.js'; +import { type Context, BaseTask } from './BaseTask.js'; + +export interface Config { + inputs: string[]; + output: string; + maxShDegree?: number; +} + +export class ReadTask extends BaseTask { + override async exec(config: Config, { logger, resources }: Context) { + const { inputs, output, maxShDegree } = config; + const splat = new SplatData(inputs.length, maxShDegree); + const promises: Array> = []; + let totalBytes: number = 0; + for (let i = 0; i < inputs.length; i++) { + const path = inputs[i]; + const { size } = fs.statSync(path); + totalBytes += size; + const stream = Readable.toWeb(fs.createReadStream(path)) as ReadableStream; + const promise = createSplatFile(path).read(stream, size, splat); + promises.push(promise); + } + await Promise.all(promises); + logger.info(`load: ${inputs.length} files | sizes=${(totalBytes / 1024 / 1024).toFixed(2)}MB`); + for (let i = 0; i < inputs.length; i++) { + logger.info(` - ${inputs[i]}`); + } + logger.info(`counts: ${splat.counts}, SH: ${splat.shDegree}`); + resources.set(output, splat); + logger.info(`stored -> "${output}"`); + } +} diff --git a/packages/splat-transform/src/tasks/SkeletonLodTask.ts b/packages/splat-transform/src/tasks/SkeletonLodTask.ts new file mode 100644 index 0000000..fab38d6 --- /dev/null +++ b/packages/splat-transform/src/tasks/SkeletonLodTask.ts @@ -0,0 +1,195 @@ +import { ColIdx, type ISingleSplat, SplatData } from '../SplatData.js'; +import { type Context, BaseTask } from './BaseTask.js'; + +export interface Config { + input: string; + output: string; + counts?: number; + ratio?: number; +} + +const VOXEL_CHUNK_SIZE = 0.02; +const VOXEL_CHUNK_SCALE = 1.3; +export class SkeletonLodTask extends BaseTask { + override async exec(config: Config, { logger, resources }: Context) { + const { input, output, counts = 85000, ratio = 0.1 } = config; + const splat = resources.get(input) as SplatData; + logger.info(`loaded -> "${input}"`); + const target = Math.min(Math.ceil(splat.counts * ratio), counts); + logger.info( + `expected -> ${target}(${((target / splat.counts) * 100).toFixed(2)}%) | ratio=${ratio} counts=${counts}`, + ); + const xCol = splat.table[ColIdx.x]; + const yCol = splat.table[ColIdx.y]; + const zCol = splat.table[ColIdx.z]; + + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + for (let i = 0; i < splat.counts; i++) { + const x = xCol[i]; + const y = yCol[i]; + const z = zCol[i]; + if (x < minX) { + minX = x; + } + if (y < minY) { + minY = y; + } + if (z < minZ) { + minZ = z; + } + } + + const chunkMap = new Map(); + for (let i = 0; i < splat.counts; i++) { + const x = ((xCol[i] - minX) / VOXEL_CHUNK_SIZE) | 0; + const y = ((yCol[i] - minY) / VOXEL_CHUNK_SIZE) | 0; + const z = ((zCol[i] - minZ) / VOXEL_CHUNK_SIZE) | 0; + const key = `${x},${y},${z}`; + let arr = chunkMap.get(key); + if (!arr) { + arr = []; + chunkMap.set(key, arr); + } + arr.push(i); + } + + const chunks = Array.from(chunkMap.values()); + + const CHUNK_RATIO = chunks.reduce((p, c) => p + c.length ** VOXEL_CHUNK_SCALE, 0) / (target * 0.1); + const mergeChucks: number[][] = []; + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const size = Math.max(1, Math.ceil(chunk.length ** VOXEL_CHUNK_SCALE / CHUNK_RATIO)); + if (size === 1) { + mergeChucks.push(chunk); + continue; + } + + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + for (let j = 0; j < chunk.length; j++) { + const offset = chunk[j]; + const x = xCol[offset]; + const y = yCol[offset]; + const z = zCol[offset]; + if (x < minX) { + minX = x; + } + if (y < minY) { + minY = y; + } + if (z < minZ) { + minZ = z; + } + if (x > maxX) { + maxX = x; + } + if (y > maxY) { + maxY = y; + } + if (z > maxZ) { + maxZ = z; + } + } + const subChuckSize = Math.ceil(Math.cbrt(size)); + const scaleX = subChuckSize / Math.max(maxX - minX, 1e-9); + const scaleY = subChuckSize / Math.max(maxY - minY, 1e-9); + const scaleZ = subChuckSize / Math.max(maxZ - minZ, 1e-9); + + const subChunkMap = new Map(); + for (let j = 0; j < chunk.length; j++) { + const idx = chunk[j]; + const x = ((xCol[idx] - minX) * scaleX) | 0; + const y = ((yCol[idx] - minY) * scaleY) | 0; + const z = ((zCol[idx] - minZ) * scaleZ) | 0; + const key = `${x},${y},${z}`; + let arr = subChunkMap.get(key); + if (!arr) { + arr = []; + subChunkMap.set(key, arr); + } + arr.push(idx); + } + const subChunks = Array.from(subChunkMap.values()); + if (subChunks.length > size) { + subChunks.sort((a, b) => b.length - a.length); + subChunks.length = size; + } + for (let j = 0; j < subChunks.length; j++) { + mergeChucks.push(subChunks[j]); + } + } + if (mergeChucks.length > target) { + mergeChucks.sort((a, b) => b.length - a.length); + mergeChucks.length = target; + } + + const raw = new SplatData().init(mergeChucks.length, 0); + + const result: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0.005, + sy: 0.005, + sz: 0.005, + qx: 0, + qy: 0, + qz: 0, + qw: 1, + r: 0, + g: 0, + b: 0, + a: 0, + shN: [], + }; + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: [], + }; + for (let i = 0; i < mergeChucks.length; i++) { + const chunk = mergeChucks[i]; + for (let j = 0; j < chunk.length; j++) { + splat.get(chunk[j], single); + result.x += single.x; + result.y += single.y; + result.z += single.z; + result.r += single.r; + result.g += single.g; + result.b += single.b; + result.a += single.a; + } + result.x /= chunk.length; + result.y /= chunk.length; + result.z /= chunk.length; + result.r /= chunk.length; + result.g /= chunk.length; + result.b /= chunk.length; + result.a /= chunk.length; + raw.set(i, result); + result.x = result.y = result.z = result.r = result.g = result.b = result.a = 0; + } + + resources.set(output, raw); + logger.info(`stored -> key="${output}"`); + } +} diff --git a/packages/splat-transform/src/tasks/SplitSplatTask.ts b/packages/splat-transform/src/tasks/SplitSplatTask.ts new file mode 100644 index 0000000..da9af4b --- /dev/null +++ b/packages/splat-transform/src/tasks/SplitSplatTask.ts @@ -0,0 +1,33 @@ +import { splitSplat } from '../native/index.js'; +import type { SplatData } from '../SplatData.js'; +import { type Context, BaseTask, type SingleFile } from './BaseTask.js'; + +export interface Config { + input: string; + output: string; + type: string; + blockPrecision?: number; +} + +export class SplitSplatTask extends BaseTask { + override exec(config: Config, { logger, resources }: Context) { + const { input, output, type, blockPrecision = 0.5 } = config; + const splat = resources.get(input) as SplatData; + logger.info(`loaded -> "${input}"`); + logger.info(`block precision -> ${blockPrecision}`); + logger.info('splitting splat'); + logger.time('split elapsed'); + const { splats } = splitSplat(splat, blockPrecision); + logger.timeEnd('split elapsed'); + const outputs: SingleFile[] = []; + for (let i = 0; i < splats.length; i++) { + outputs.push({ + name: `block_${i}.${type}`, + content: splats[i], + }); + } + + resources.set(output, outputs); + return Promise.resolve(); + } +} diff --git a/packages/splat-transform/src/tasks/VoxelTask.ts b/packages/splat-transform/src/tasks/VoxelTask.ts new file mode 100644 index 0000000..ed8b9ae --- /dev/null +++ b/packages/splat-transform/src/tasks/VoxelTask.ts @@ -0,0 +1,83 @@ +import { SplatData } from '../SplatData.js'; +import { writeVoxelFiles, type AutoDenseBoxConfig, type VoxelBinaryCompression } from '../file/voxel.js'; +import type { FilterClusterOptions } from '../utils/voxel/filterCluster.js'; +import type { VoxelNodeEncoding } from '../utils/voxel/binary.js'; +import { type Context, BaseTask } from './BaseTask.js'; + +export interface VoxelTaskConfig { + input: string; + output: string; + voxelResolution?: number; + opacityCutoff?: number; + backend?: 'cpu' | 'gpu'; + collisionMesh?: boolean | 'smooth' | 'faces'; + navExteriorRadius?: number; + floorFill?: boolean; + floorFillDilation?: number; + cpuWorkerCount?: number; + box?: { minCorner: [number, number, number]; maxCorner: [number, number, number] }; + navCapsule?: { height: number; radius: number }; + navSeed?: { x: number; y: number; z: number }; + compression?: VoxelBinaryCompression; + nodeEncoding?: VoxelNodeEncoding; + filterCluster?: boolean | FilterClusterOptions; + autoDenseBox?: AutoDenseBoxConfig; +} + +export class VoxelTask extends BaseTask { + override async exec(config: VoxelTaskConfig, { logger, resources }: Context) { + const { + input, + output, + voxelResolution = 0.05, + opacityCutoff = 0.1, + backend = 'gpu', + collisionMesh = false, + navExteriorRadius, + floorFill = false, + floorFillDilation = 0, + cpuWorkerCount = -1, + box = { minCorner: [-100, -100, -100], maxCorner: [100, 100, 100] }, + navCapsule, + navSeed, + compression = 'none', + nodeEncoding = 'raw', + filterCluster = true, + autoDenseBox = true, + } = config; + const source = resources.get(input)!; + if (!(source instanceof SplatData)) { + throw new Error(`VoxelTask: resource "${input}" must be SplatData`); + } + const options: Parameters[2] = { + voxelResolution, + opacityCutoff, + backend, + collisionMesh, + floorFill, + floorFillDilation, + cpuWorkerCount, + box, + compression, + nodeEncoding, + filterCluster, + autoDenseBox, + }; + if (navExteriorRadius !== undefined) { + options.navExteriorRadius = navExteriorRadius; + } + if (navCapsule !== undefined) { + options.navCapsule = navCapsule; + } + if (navSeed !== undefined) { + options.navSeed = navSeed; + } + logger.info(`writing voxel -> dir="${output}" count=${source.counts} SH=${source.shDegree}`); + await writeVoxelFiles(output, source, options); + logger.info('voxelizing done'); + } + + override requiresGPU(_config: VoxelTaskConfig): boolean { + return (_config.backend ?? 'gpu') === 'gpu'; + } +} diff --git a/packages/splat-transform/src/tasks/WriteTask.ts b/packages/splat-transform/src/tasks/WriteTask.ts new file mode 100644 index 0000000..701f55b --- /dev/null +++ b/packages/splat-transform/src/tasks/WriteTask.ts @@ -0,0 +1,176 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { Worker } from 'node:worker_threads'; +import { SplatData } from '../SplatData.js'; +import { + deferred, + detectSplatFileType, + SplatFileType, + WorkerPool, + writeSplatFile, + type Deferred, + type ISplatData, +} from '../utils/index.js'; +import { type Context, BaseTask } from './BaseTask.js'; + +export interface Config { + input: string; + output: string; + parallelCounts?: number; + enableMortonSort?: boolean; + compressLevel?: number; + spzVersion?: number; +} + +export class WriteTask extends BaseTask { + override async exec(config: Config, { logger, resources }: Context) { + const { + input, + output, + parallelCounts = Math.max(1, os.cpus().length - 1), + enableMortonSort = true, + compressLevel, + spzVersion, + } = config; + const pool = new WorkerPool( + 'splat-write', + () => new Worker(new URL('../workers/write.js', import.meta.url)), + parallelCounts, + ); + + const source = resources.get(input)!; + if (source instanceof SplatData) { + logger.info(`writing splat -> file="${output}" count=${source.counts} SH=${source.shDegree}`); + await writeSplatFile(output, source, enableMortonSort, compressLevel, spzVersion); + logger.info(`writing done`); + return; + } + + if (fs.existsSync(output)) { + fs.rmSync(output, { recursive: true }); + logger.info(`exist dir ${output}, removed`); + } + fs.mkdirSync(output, { recursive: true }); + + logger.info(`writing bundle -> dir="${output}" files=${source.length}`); + logger.silent = true; + const sogList: Array<{ name: string; content: SplatData }> = []; + const promises: Array> = []; + let idx: number = 1; + const totals = source.length; + for (let i = 0; i < source.length; i++) { + const { name, content, preserveOrder = false } = source[i]; + const filepath = path.join(output, name); + if (typeof content === 'string') { + logger.info(`- ${filepath} (${idx++}/${totals})`, true); + fs.writeFileSync(filepath, content); + continue; + } + const type = detectSplatFileType(filepath); + if (type === SplatFileType.SOG) { + sogList.push({ name, content: content as SplatData }); + continue; + } + + logger.info(`- ${filepath} (${idx++}/${totals})`, true); + const d = deferred(); + await runWriteTask( + pool, + { + filepath, + data: content.serialize(), + enableMortonSort: enableMortonSort && !preserveOrder, + compressLevel, + spzVersion, + }, + d, + ); + promises.push(d.promise); + } + await Promise.all(promises); + await pool.dispose(); + for (let i = 0; i < sogList.length; i++) { + const { name, content } = sogList[i]; + const filepath = path.join(output, name); + logger.info(`- ${filepath} (${idx++}/${totals})`, true); + await writeSplatFile(filepath, content, false); + } + logger.silent = false; + logger.info(`writing bundle done -> dir="${output}" files=${source.length}`); + } + + override requiresGPU(config: Config): boolean { + return config.output.endsWith('sog'); + } +} + +interface WriteWorkerTask { + filepath: string; + data: ISplatData; + enableMortonSort: boolean; + compressLevel: number | undefined; + spzVersion: number | undefined; +} + +function createWorkerError(filepath: string, reason: unknown): Error { + if (reason instanceof Error) { + return reason; + } + return new Error(`Write worker failed for "${filepath}": ${String(reason)}`); +} + +async function runWriteTask(pool: WorkerPool, task: WriteWorkerTask, deferred: Deferred) { + const worker = await pool.getWorker(); + + const cleanup = () => { + worker.off('message', onMessage); + worker.off('error', onError); + worker.off('exit', onExit); + }; + + let settled = false; + const finish = (error?: unknown, releaseWorker = true) => { + if (settled) { + return; + } + settled = true; + cleanup(); + if (releaseWorker) { + worker.release(); + } + if (error === undefined) { + deferred.resolve(); + } else { + deferred.reject(createWorkerError(task.filepath, error)); + } + }; + + const onMessage = (msg: unknown) => { + const result = msg != null && typeof msg === 'object' ? (msg as any) : undefined; + if (result?.success === true) { + finish(); + } else { + finish(result?.content ?? 'Worker returned a malformed response'); + } + }; + + const onError = (error: Error) => { + finish(error, false); + void worker.terminate(); + }; + + const onExit = (code: number) => { + finish(new Error(`Write worker exited with code ${code} before completing "${task.filepath}"`), false); + }; + + worker.once('message', onMessage); + worker.once('error', onError); + worker.once('exit', onExit); + + try { + worker.postMessage(task, task.data.table.map(v => v.buffer) as any); + } catch (error) { + finish(error); + } +} diff --git a/packages/splat-transform/src/utils/BufferReader.ts b/packages/splat-transform/src/utils/BufferReader.ts new file mode 100644 index 0000000..258d620 --- /dev/null +++ b/packages/splat-transform/src/utils/BufferReader.ts @@ -0,0 +1,54 @@ +export class BufferReader { + head = 0; + tail = 0; + buffer: Uint8Array; + view: DataView; + + get remaining(): number { + return this.tail - this.head; + } + + constructor(buffer: Uint8Array = new Uint8Array()) { + this.buffer = buffer; + this.view = new DataView(this.buffer.buffer); + } + + private grow(required: number) { + const newCap = Math.max(required, this.buffer.length * 2); + const next = new Uint8Array(newCap); + next.set(this.buffer.subarray(this.head, this.tail), 0); + + this.tail -= this.head; + this.head = 0; + this.buffer = next; + this.view = new DataView(next.buffer); + } + + private compact() { + if (this.head === 0) { + return; + } + this.buffer.copyWithin(0, this.head, this.tail); + this.tail -= this.head; + this.head = 0; + } + + write(chunk: Uint8Array) { + const remaining = this.tail - this.head; + const required = remaining + chunk.length; + if (this.buffer.length < required) { + this.grow(required); + } else if (this.head > 0 && this.buffer.length - this.tail < chunk.length) { + this.compact(); + } + + this.buffer.set(chunk, this.tail); + this.tail += chunk.length; + } + + read(counts: number): Uint8Array { + const head = this.head; + const tail = (this.head = head + counts); + return this.buffer.subarray(head, tail); + } +} diff --git a/packages/splat-transform/src/utils/Logger.ts b/packages/splat-transform/src/utils/Logger.ts new file mode 100644 index 0000000..3fa3a15 --- /dev/null +++ b/packages/splat-transform/src/utils/Logger.ts @@ -0,0 +1,45 @@ +export class Logger { + prefix: string = ''; + silent: boolean = false; + + private format(msg: string) { + return this.prefix ? `${this.prefix} ${msg}` : msg; + } + + info(msg: string, force: boolean = false) { + if (this.silent && !force) { + return; + } + console.log(this.format(msg)); + } + + warn(msg: string, force: boolean = false) { + if (this.silent && !force) { + return; + } + console.warn(this.format(msg)); + } + + error(msg: string, force: boolean = false) { + if (this.silent && !force) { + return; + } + console.error(this.format(msg)); + } + + time(label: string) { + if (this.silent) { + return; + } + console.time(this.format(label)); + } + + timeEnd(label: string) { + if (this.silent) { + return; + } + console.timeEnd(this.format(label)); + } +} + +export const logger = new Logger(); diff --git a/packages/splat-transform/src/utils/StreamChunkDecoder.ts b/packages/splat-transform/src/utils/StreamChunkDecoder.ts new file mode 100644 index 0000000..1ce899b --- /dev/null +++ b/packages/splat-transform/src/utils/StreamChunkDecoder.ts @@ -0,0 +1,46 @@ +import type { BufferReader } from './BufferReader.js'; + +export interface ChunkDecoder { + init(): [number, number]; // [totals, itemSize] + decode(offset: number, counts: number, buffer: Uint8Array): void; +} + +export class StreamChunkDecoder { + private reader: BufferReader; + private decoders: ChunkDecoder[]; + private decodedTotals: Uint32Array; + private currentIndex: number = 0; + private currentTotals: number; + private currentItemSize: number; + + constructor(reader: BufferReader) { + this.reader = reader; + } + + setDecoders(decoders: ChunkDecoder[]) { + this.decoders = decoders; + this.decodedTotals = new Uint32Array(decoders.length); + const [totals, itemSize] = decoders[this.currentIndex].init(); + this.currentTotals = totals; + this.currentItemSize = itemSize; + } + + flush() { + const { reader, decoders, decodedTotals, currentIndex, currentTotals, currentItemSize } = this; + const stage = decoders[currentIndex]; + const decoded = decodedTotals[currentIndex]; + const counts = Math.min(currentTotals - decoded, (reader.remaining / currentItemSize) | 0); + const buf = reader.read(counts * currentItemSize); + stage.decode(decoded, counts, buf); + decodedTotals[currentIndex] += counts; + if (decodedTotals[currentIndex] === currentTotals) { + this.currentIndex++; + if (this.currentIndex < decoders.length) { + const [totals, itemSize] = decoders[this.currentIndex]!.init(); + this.currentTotals = totals; + this.currentItemSize = itemSize; + this.flush(); + } + } + } +} diff --git a/packages/splat-transform/src/utils/index.ts b/packages/splat-transform/src/utils/index.ts new file mode 100644 index 0000000..719b756 --- /dev/null +++ b/packages/splat-transform/src/utils/index.ts @@ -0,0 +1,112 @@ +export interface Deferred { + resolve: (value: T | PromiseLike) => void; + reject: (reason: any) => void; + promise: Promise; +} + +export function deferred(): Deferred { + let resolve: (value: T | PromiseLike) => void = () => {}; + let reject: (reason: any) => void = () => {}; + const promise = new Promise(function (resolveInner, rejectInner) { + resolve = resolveInner; + reject = rejectInner; + }); + return { + promise, + resolve, + reject, + }; +} + +export function sleep(timeout: number) { + return new Promise(resolve => { + setTimeout(resolve, timeout); + }); +} + +export function clamp(v: number, min: number, max: number): number { + return Math.min(Math.max(v, min), max); +} + +export function isUrl(str: string): boolean { + let url: URL; + try { + url = new URL(str); + } catch { + return false; + } + + return url.protocol === 'http:' || url.protocol === 'https:'; +} + +export function extractFromRootDir(entries: Record): Record { + let dir: string = ''; + for (const path in entries) { + if (path.endsWith('/')) { + dir = path; + break; + } + } + const result: Record = {}; + for (const path in entries) { + result[path.replace(dir, '')] = entries[path]; + } + return result; +} + +const f32buffer = new Float32Array(1); +const u32buffer = new Uint32Array(f32buffer.buffer); +export function fromHalf(h: number): number { + const sign = (h >> 15) & 0x1; + const exp = (h >> 10) & 0x1f; + const frac = h & 0x3ff; + + let f32bits: number; + if (exp === 0) { + if (frac === 0) { + f32bits = sign << 31; + } else { + let mant = frac; + let e = -14; + while ((mant & 0x400) === 0) { + mant <<= 1; + e--; + } + mant &= 0x3ff; + const newExp = e + 127; + const newFrac = mant << 13; + f32bits = (sign << 31) | (newExp << 23) | newFrac; + } + } else if (exp === 0x1f) { + if (frac === 0) { + f32bits = (sign << 31) | 0x7f800000; + } else { + f32bits = (sign << 31) | 0x7fc00000; + } + } else { + const newExp = exp - 15 + 127; + const newFrac = frac << 13; + f32bits = (sign << 31) | (newExp << 23) | newFrac; + } + + u32buffer[0] = f32bits; + return f32buffer[0]; +} + +export * from './Logger.js'; +export * from './BufferReader.js'; +export * from './StreamChunkDecoder.js'; +export * from './math.js'; +export * from './shRotate.js'; +export * from './splat.js'; +export * from './k-means/index.js'; +export * from './quantize1d.js'; +export * from './worker.js'; +export * from './webgpu.js'; +export * from './voxel/common.js'; +export * from './voxel/voxelize.js'; +export * from './voxel/postprocess.js'; +export * from './voxel/nav.js'; +export * from './voxel/mesh.js'; +export * from './voxel/voxelFaces.js'; +export * from './voxel/gpuDilation.js'; diff --git a/packages/splat-transform/src/utils/k-means/GpuClustering.ts b/packages/splat-transform/src/utils/k-means/GpuClustering.ts new file mode 100644 index 0000000..5738c33 --- /dev/null +++ b/packages/splat-transform/src/utils/k-means/GpuClustering.ts @@ -0,0 +1,399 @@ +import { logger } from '../index.js'; + +const chunkSize = 128; +const workgroupSize = 128; + +function clusterWgsl(vecColumns: number) { + return /* wgsl */ ` +struct Uniforms { + numPoints: u32, + numCentroids: u32 +}; + +@group(0) @binding(0) var uniforms: Uniforms; +@group(0) @binding(1) var points: array>; +@group(0) @binding(2) var centroids: array>; +@group(0) @binding(3) var centroidSq: array; +@group(0) @binding(4) var results: array; + +const vecColumns = ${vecColumns}u; +const chunkSize = ${chunkSize}u; +const workgroupSize = ${workgroupSize}u; +var sharedChunk: array, vecColumns * chunkSize>; +var sharedSq: array; + +fn calcDistance(point: array, vecColumns>, centroid: u32) -> f32 { + let ci = centroid * vecColumns; + var result = sharedSq[centroid]; + + for (var i = 0u; i < vecColumns; i++) { + // euclid distance simplify + // (centroid - point) ^ 2 = centroid ^ 2 - 2 * dot(centroid, point) + point ^ 2 + // point ^ 2 omitted, for same point find nearest centroid is not necessary + result -= 2.0 * dot(point[i], sharedChunk[ci + i]); + } + + return result; +} + +@compute @workgroup_size(workgroupSize) +fn main( + @builtin(local_invocation_index) localId : u32, + @builtin(global_invocation_id) globalId: vec3u, + @builtin(num_workgroups) numWorkgroups: vec3u +) { + let pointIndex = globalId.x + globalId.y * numWorkgroups.x * workgroupSize; + + var point: array, vecColumns>; + if (pointIndex < uniforms.numPoints) { + for (var i = 0u; i < vecColumns; i++) { + point[i] = points[pointIndex * vecColumns + i]; + } + } + + var mind = 1000000.0; + var mini = 0u; + + let numChunks = u32(ceil(f32(uniforms.numCentroids) / f32(chunkSize))); + for (var i = 0u; i < numChunks; i++) { + let chunkToLoad = min(chunkSize, uniforms.numCentroids - i * chunkSize); + for (var row = localId; row < chunkToLoad; row += workgroupSize) { + let srcRow = i * chunkSize + row; + let dst = row * vecColumns; + let src = srcRow * vecColumns; + + for (var c = 0u; c < vecColumns; c++) { + sharedChunk[dst + c] = centroids[src + c]; + } + sharedSq[row] = centroidSq[srcRow]; + } + + workgroupBarrier(); + + if (pointIndex < uniforms.numPoints) { + let thisChunkSize = min(chunkSize, uniforms.numCentroids - i * chunkSize); + for (var c = 0u; c < thisChunkSize; c++) { + let d = calcDistance(point, c); + if (d < mind) { + mind = d; + mini = i * chunkSize + c; + } + } + } + + workgroupBarrier(); + } + + if (pointIndex < uniforms.numPoints) { + results[pointIndex] = mini; + } +} +`; +} + +interface GpuResource { + pipeline: GPUComputePipeline; + bindGroups: GPUBindGroup[]; + gpuBuffers: { + uniform: GPUBuffer; + points: GPUBuffer[]; + centroids: GPUBuffer; + centroidSq: GPUBuffer; + result: GPUBuffer; + resultReadBack: GPUBuffer; + }; + backBuffers: { + uniform: Uint32Array; + points: Float32Array; + centroids: Float32Array; + centroidSq: Float32Array; + }; + uploadedBatches: number[]; +} + +function packVec4Data( + result: Float32Array, + dataTable: Float32Array[], + numRows: number, + rowOffset: number, + vecColumns: number, + norms?: Float32Array, +) { + const numColumns = dataTable.length; + const stride = vecColumns * 4; + + for (let r = 0; r < numRows; r++) { + const dst = r * stride; + let norm = 0.0; + + for (let c = 0; c < numColumns; c++) { + const v = dataTable[c][rowOffset + r]; + result[dst + c] = v; + if (norms) { + norm += v * v; + } + } + + for (let c = numColumns; c < stride; c++) { + result[dst + c] = 0.0; + } + + if (norms) { + norms[r] = norm; + } + } +} + +const MAX_CONCURRENCY_BATCHES = 10; + +export default class GpuClustering { + private batchSize: number; + private resource: GpuResource; + private numBatches: number; + private concurrencyBatches: number; + private concurrencyRuns: number; + private workgroupSize: number; + private pointStride: number; + private vecColumns: number; + + constructor( + private device: GPUDevice, + private numPoints: number, + numColumns: number, + private numCentroids: number, + ) { + this.vecColumns = Math.ceil(numColumns / 4); + this.workgroupSize = workgroupSize; + this.pointStride = this.vecColumns * 4; + const storageLimit = Math.min(device.limits.maxBufferSize, device.limits.maxStorageBufferBindingSize); + const workgroupsPerBatch = Math.max( + 1, + Math.min( + device.limits.maxComputeWorkgroupsPerDimension, // device dispatch limit + Math.floor(storageLimit / (this.pointStride * this.workgroupSize * 4)), // point storage limit + Math.ceil(numPoints / this.workgroupSize), // max limit + ), + ); + this.batchSize = workgroupsPerBatch * this.workgroupSize; + this.numBatches = Math.ceil(numPoints / this.batchSize); + this.concurrencyBatches = Math.min(MAX_CONCURRENCY_BATCHES, this.numBatches); + this.concurrencyRuns = Math.ceil(this.numBatches / this.concurrencyBatches); + + const shader = device.createShaderModule({ + code: clusterWgsl(this.vecColumns), + }); + const pipeline = device.createComputePipeline({ + layout: 'auto', + compute: { + module: shader, + entryPoint: 'main', + }, + }); + const pointsBackBuffer = new Float32Array(this.pointStride * this.batchSize); + const centroidsBackBuffer = new Float32Array(this.pointStride * numCentroids); + const centroidSqBackBuffer = new Float32Array(numCentroids); + const uniformBackBuffer = new Uint32Array([0, numCentroids]); + const pointsBuffers: GPUBuffer[] = []; + + const centroidsBuffer = device.createBuffer({ + size: centroidsBackBuffer.byteLength, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE, + }); + const centroidSqBuffer = device.createBuffer({ + size: centroidSqBackBuffer.byteLength, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE, + }); + const uniformBuffer = device.createBuffer({ + size: 256 * this.concurrencyBatches, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.UNIFORM, + }); + const resultBuffer = device.createBuffer({ + size: this.concurrencyBatches * this.batchSize * 4, + usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.STORAGE, + }); + const resultReadBackBuffer = device.createBuffer({ + size: this.concurrencyBatches * this.batchSize * 4, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, + }); + + const layout = pipeline.getBindGroupLayout(0); + const bindGroups: GPUBindGroup[] = []; + for (let i = 0; i < this.concurrencyBatches; i++) { + const pointsBuffer = device.createBuffer({ + size: pointsBackBuffer.byteLength, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE, + }); + pointsBuffers.push(pointsBuffer); + + const entries: GPUBindGroupEntry[] = [ + { + binding: 0, + resource: { + buffer: uniformBuffer, + offset: i * 256, + size: 8, + }, + }, + { + binding: 1, + resource: pointsBuffer, + }, + { + binding: 2, + resource: centroidsBuffer, + }, + { + binding: 3, + resource: centroidSqBuffer, + }, + { + binding: 4, + resource: { + buffer: resultBuffer, + offset: i * this.batchSize * 4, + size: this.batchSize * 4, + }, + }, + ]; + + bindGroups.push( + device.createBindGroup({ + layout, + entries, + }), + ); + } + + const gpuBuffers: GpuResource['gpuBuffers'] = { + uniform: uniformBuffer, + points: pointsBuffers, + centroids: centroidsBuffer, + centroidSq: centroidSqBuffer, + result: resultBuffer, + resultReadBack: resultReadBackBuffer, + }; + + const backBuffers: GpuResource['backBuffers'] = { + uniform: uniformBackBuffer, + points: pointsBackBuffer, + centroids: centroidsBackBuffer, + centroidSq: centroidSqBackBuffer, + }; + + this.resource = { + pipeline, + bindGroups, + gpuBuffers, + backBuffers, + uploadedBatches: [], + }; + logger.info( + `GPU k-means kernel bootstrapped with batch ${workgroupsPerBatch}*${this.workgroupSize}*${this.numBatches}, concurrency: ${this.concurrencyBatches}, runs: ${this.concurrencyRuns}`, + ); + } + + async execute(points: Float32Array[], centroids: Float32Array[], labels: Uint32Array) { + const { + device, + numPoints, + numCentroids, + numBatches, + batchSize, + resource, + concurrencyBatches, + concurrencyRuns, + pointStride, + vecColumns, + workgroupSize, + } = this; + + // upload centroid data to gpu + packVec4Data( + resource.backBuffers.centroids, + centroids, + numCentroids, + 0, + vecColumns, + resource.backBuffers.centroidSq, + ); + device.queue.writeBuffer(resource.gpuBuffers.centroids, 0, resource.backBuffers.centroids.buffer); + device.queue.writeBuffer(resource.gpuBuffers.centroidSq, 0, resource.backBuffers.centroidSq.buffer); + + for (let i = 0; i < concurrencyRuns; i++) { + const batchStart = i * concurrencyBatches; + let resultCount = 0; + for (let j = 0; j < concurrencyBatches; j++) { + const batchIndex = batchStart + j; + if (batchIndex >= numBatches) { + break; + } + const currentBatchSize = Math.min(numPoints - batchIndex * batchSize, batchSize); + resultCount += currentBatchSize; + + // write this batch of point data to gpu + if (resource.uploadedBatches[j] !== batchIndex) { + packVec4Data( + resource.backBuffers.points, + points, + currentBatchSize, + batchIndex * batchSize, + vecColumns, + ); + device.queue.writeBuffer( + resource.gpuBuffers.points[j], + 0, + resource.backBuffers.points.buffer, + 0, + pointStride * currentBatchSize * 4, + ); + resource.backBuffers.uniform[0] = currentBatchSize; + device.queue.writeBuffer( + resource.gpuBuffers.uniform, + 256 * j, + resource.backBuffers.uniform.buffer, + 0, + 8, + ); + resource.uploadedBatches[j] = batchIndex; + } + } + const encoder = device.createCommandEncoder(); + const computePass = encoder.beginComputePass(); + computePass.setPipeline(resource.pipeline); + for (let j = 0; j < concurrencyBatches; j++) { + const batchIndex = batchStart + j; + if (batchIndex >= numBatches) { + break; + } + const currentBatchSize = Math.min(numPoints - batchIndex * batchSize, batchSize); + const groups = Math.ceil(currentBatchSize / workgroupSize); + computePass.setBindGroup(0, resource.bindGroups[j]); + computePass.dispatchWorkgroups(groups); + } + computePass.end(); + encoder.copyBufferToBuffer( + resource.gpuBuffers.result, + 0, + resource.gpuBuffers.resultReadBack, + 0, + resultCount * 4, + ); + device.queue.submit([encoder.finish()]); + await resource.gpuBuffers.resultReadBack.mapAsync(GPUMapMode.READ); + const mapped = resource.gpuBuffers.resultReadBack.getMappedRange(); + labels.set(new Uint32Array(mapped, 0, resultCount), batchStart * batchSize); + resource.gpuBuffers.resultReadBack.unmap(); + } + } + + destroy() { + this.resource.gpuBuffers.uniform.destroy(); + this.resource.gpuBuffers.centroids.destroy(); + this.resource.gpuBuffers.centroidSq?.destroy(); + this.resource.gpuBuffers.result.destroy(); + this.resource.gpuBuffers.resultReadBack.destroy(); + for (const buffer of this.resource.gpuBuffers.points) { + buffer.destroy(); + } + } +} diff --git a/packages/splat-transform/src/utils/k-means/index.ts b/packages/splat-transform/src/utils/k-means/index.ts new file mode 100644 index 0000000..aa10134 --- /dev/null +++ b/packages/splat-transform/src/utils/k-means/index.ts @@ -0,0 +1,80 @@ +import { clusterAverage } from '../../native/index.js'; +import { logger } from '../index.js'; +import GpuClustering from './GpuClustering.js'; + +// in the 1d case we use quantile-based initialization for better handling of skewed data +function initializeCentroids1D(data: Float32Array, centroids: Float32Array) { + const n = data.length; + const k = centroids.length; + + // Sort data to compute quantiles + const sorted = Float32Array.from(data).sort((a, b) => a - b); + + for (let i = 0; i < k; ++i) { + // Place centroid at the center of its expected cluster region + const quantile = (2 * i + 1) / (2 * k); + const index = Math.min(Math.floor(quantile * n), n - 1); + centroids[i] = sorted[index]; + } +} + +// use floyd's algorithm to pick m unique random indices from 0..n-1 +function pickRandomIndices(n: number, m: number) { + const chosen = new Set(); + for (let j = n - m; j < n; j++) { + const t = Math.floor(Math.random() * (j + 1)); + chosen.add(chosen.has(t) ? j : t); + } + return [...chosen]; +} + +function initializeCentroids(dataTable: Float32Array[], centroids: Float32Array[]) { + const indices = pickRandomIndices(dataTable[0].length, centroids[0].length); + for (let i = 0; i < centroids[0].length; i++) { + for (let j = 0; j < dataTable.length; j++) { + centroids[j][i] = dataTable[j][indices[i]]; + } + } +} + +// https://github.com/playcanvas/splat-transform/blob/main/src/lib/spatial/k-means.ts +export async function kMeans(points: Float32Array[], k: number, iterations: number, device: GPUDevice) { + const numRows = points.length > 0 ? points[0].length : 0; + if (numRows < k) { + return { + centroids: points, + // use a typed array here so downstream code can rely on + // labels supporting subarray(), even in this early-return + // path used for very small datasets. + labels: new Uint32Array(numRows).map((_, i) => i), + }; + } + const centroids = points.map(_ => new Float32Array(k)); + if (points.length === 1) { + initializeCentroids1D(points[0], centroids[0]); + } else { + initializeCentroids(points, centroids); + } + const gpuClustering = new GpuClustering(device, numRows, points.length, k); + const labels = new Uint32Array(numRows); + let converged = false; + let steps = 0; + + while (!converged) { + logger.info(`kmeans iteration ${steps + 1}`); + await gpuClustering.execute(points, centroids, labels); + clusterAverage(points, labels, k, centroids); + + steps++; + + if (steps >= iterations) { + converged = true; + } + } + gpuClustering.destroy(); + + return { + centroids, + labels, + }; +} diff --git a/packages/splat-transform/src/utils/math.ts b/packages/splat-transform/src/utils/math.ts new file mode 100644 index 0000000..fafbe19 --- /dev/null +++ b/packages/splat-transform/src/utils/math.ts @@ -0,0 +1,441 @@ +export class Quaternion { + static ONE = new Quaternion(0, 0, 0, 1); + + constructor( + public x: number, + public y: number, + public z: number, + public w: number, + ) {} + + set(x: number, y: number, z: number, w: number): this { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + return this; + } + + equals(q: Quaternion): boolean { + return q.x === this.x && q.y === this.y && q.z === this.z && q.w === this.w; + } + + normalize(): this { + const length = Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z + this.w * this.w); + if (length === 0) { + return this; + } + const invLength = (1 / length) * (this.w < 0 ? -1 : 1); + this.x *= invLength; + this.y *= invLength; + this.z *= invLength; + this.w *= invLength; + return this; + } + + multiply(q: Quaternion): Quaternion { + return this.multiplyQuaternions(this, q); + } + + premultiply(q: Quaternion): Quaternion { + return this.multiplyQuaternions(q, this); + } + + multiplyQuaternions(a: Quaternion, b: Quaternion): this { + // from http://www.euclideanspace.com/maths/algebra/realNormedAlgebra/quaternions/code/index.htm + const qax = a.x; + const qay = a.y; + const qaz = a.z; + const qaw = a.w; + const qbx = b.x; + const qby = b.y; + const qbz = b.z; + const qbw = b.w; + this.x = qax * qbw + qaw * qbx + qay * qbz - qaz * qby; + this.y = qay * qbw + qaw * qby + qaz * qbx - qax * qbz; + this.z = qaz * qbw + qaw * qbz + qax * qby - qay * qbx; + this.w = qaw * qbw - qax * qbx - qay * qby - qaz * qbz; + return this; + } + + setRotationMatrix(m: Matrix4): this { + // http://www.euclideanspace.com/maths/geometry/rotations/conversions/matrixToQuaternion/index.htm + // assumes the upper 3x3 of m is a pure rotation matrix (i.e, unscaled) + const te = m.elements; + const m11 = te[0]; + const m12 = te[4]; + const m13 = te[8]; + const m21 = te[1]; + const m22 = te[5]; + const m23 = te[9]; + const m31 = te[2]; + const m32 = te[6]; + const m33 = te[10]; + const trace = m11 + m22 + m33; + + let s: number; + if (trace > 0) { + s = 0.5 / Math.sqrt(trace + 1.0); + this.w = 0.25 / s; + this.x = (m32 - m23) * s; + this.y = (m13 - m31) * s; + this.z = (m21 - m12) * s; + } else if (m11 > m22 && m11 > m33) { + s = 2.0 * Math.sqrt(1.0 + m11 - m22 - m33); + this.w = (m32 - m23) / s; + this.x = 0.25 * s; + this.y = (m12 + m21) / s; + this.z = (m13 + m31) / s; + } else if (m22 > m33) { + s = 2.0 * Math.sqrt(1.0 + m22 - m11 - m33); + this.w = (m13 - m31) / s; + this.x = (m12 + m21) / s; + this.y = 0.25 * s; + this.z = (m23 + m32) / s; + } else { + s = 2.0 * Math.sqrt(1.0 + m33 - m11 - m22); + this.w = (m21 - m12) / s; + this.x = (m13 + m31) / s; + this.y = (m23 + m32) / s; + this.z = 0.25 * s; + } + return this; + } +} + +export class Vector3 { + static ONE = new Vector3(1, 1, 1); + + constructor( + public x: number, + public y: number, + public z: number, + ) {} + + set(x: number, y: number, z: number): this { + this.x = x; + this.y = y; + this.z = z; + return this; + } + + length(): number { + return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); + } + + equals(v: Vector3): boolean { + return v.x === this.x && v.y === this.y && v.z === this.z; + } + + mul(v: Vector3): this { + this.x *= v.x; + this.y *= v.y; + this.z *= v.z; + return this; + } + + applyMatrix4(m: Matrix4): this { + const { x, y, z } = this; + const e = m.elements; + const w = 1 / (e[3] * x + e[7] * y + e[11] * z + e[15]); + this.x = (e[0] * x + e[4] * y + e[8] * z + e[12]) * w; + this.y = (e[1] * x + e[5] * y + e[9] * z + e[13]) * w; + this.z = (e[2] * x + e[6] * y + e[10] * z + e[14]) * w; + return this; + } + + clone(): Vector3 { + return new Vector3(this.x, this.y, this.z); + } +} + +export class Matrix4 { + static ONE = new Matrix4(); + + elements = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); + + constructor(elements?: number[], isRow: boolean = false) { + if (elements) { + this.set(elements); + } + if (isRow) { + this.transpose(); + } + } + + set(elements: number[] | Float32Array): this { + for (let i = 0; i < 16; i++) { + this.elements[i] = elements[i]; + } + return this; + } + + equals(matrix: Matrix4): boolean { + const te = this.elements; + const me = matrix.elements; + for (let i = 0; i < 16; i++) { + if (te[i] !== me[i]) { + return false; + } + } + return true; + } + + determinant(): number { + const te = this.elements; + const n11 = te[0], + n12 = te[4], + n13 = te[8], + n14 = te[12], + n21 = te[1], + n22 = te[5], + n23 = te[9], + n24 = te[13], + n31 = te[2], + n32 = te[6], + n33 = te[10], + n34 = te[14], + n41 = te[3], + n42 = te[7], + n43 = te[11], + n44 = te[15]; + + // based on http://www.euclideanspace.com/maths/algebra/matrix/functions/inverse/fourD/index.htm + return ( + n41 * + (+n14 * n23 * n32 - + n13 * n24 * n32 - + n14 * n22 * n33 + + n12 * n24 * n33 + + n13 * n22 * n34 - + n12 * n23 * n34) + + n42 * + (+n11 * n23 * n34 - + n11 * n24 * n33 + + n14 * n21 * n33 - + n13 * n21 * n34 + + n13 * n24 * n31 - + n14 * n23 * n31) + + n43 * + (+n11 * n24 * n32 - + n11 * n22 * n34 - + n14 * n21 * n32 + + n12 * n21 * n34 + + n14 * n22 * n31 - + n12 * n24 * n31) + + n44 * + (-n13 * n22 * n31 - + n11 * n23 * n32 + + n11 * n22 * n33 + + n13 * n21 * n32 - + n12 * n21 * n33 + + n12 * n23 * n31) + ); + } + + multiply(m: Matrix4): Matrix4 { + return this.multiplyMatrices(this, m); + } + + multiplyMatrices(a: Matrix4, b: Matrix4): Matrix4 { + const te = this.elements; + const ae = a.elements; + const be = b.elements; + + const a11 = ae[0]; + const a12 = ae[4]; + const a13 = ae[8]; + const a14 = ae[12]; + const a21 = ae[1]; + const a22 = ae[5]; + const a23 = ae[9]; + const a24 = ae[13]; + const a31 = ae[2]; + const a32 = ae[6]; + const a33 = ae[10]; + const a34 = ae[14]; + const a41 = ae[3]; + const a42 = ae[7]; + const a43 = ae[11]; + const a44 = ae[15]; + + const b11 = be[0]; + const b12 = be[4]; + const b13 = be[8]; + const b14 = be[12]; + const b21 = be[1]; + const b22 = be[5]; + const b23 = be[9]; + const b24 = be[13]; + const b31 = be[2]; + const b32 = be[6]; + const b33 = be[10]; + const b34 = be[14]; + const b41 = be[3]; + const b42 = be[7]; + const b43 = be[11]; + const b44 = be[15]; + + te[0] = a11 * b11 + a12 * b21 + a13 * b31 + a14 * b41; + te[4] = a11 * b12 + a12 * b22 + a13 * b32 + a14 * b42; + te[8] = a11 * b13 + a12 * b23 + a13 * b33 + a14 * b43; + te[12] = a11 * b14 + a12 * b24 + a13 * b34 + a14 * b44; + + te[1] = a21 * b11 + a22 * b21 + a23 * b31 + a24 * b41; + te[5] = a21 * b12 + a22 * b22 + a23 * b32 + a24 * b42; + te[9] = a21 * b13 + a22 * b23 + a23 * b33 + a24 * b43; + te[13] = a21 * b14 + a22 * b24 + a23 * b34 + a24 * b44; + + te[2] = a31 * b11 + a32 * b21 + a33 * b31 + a34 * b41; + te[6] = a31 * b12 + a32 * b22 + a33 * b32 + a34 * b42; + te[10] = a31 * b13 + a32 * b23 + a33 * b33 + a34 * b43; + te[14] = a31 * b14 + a32 * b24 + a33 * b34 + a34 * b44; + + te[3] = a41 * b11 + a42 * b21 + a43 * b31 + a44 * b41; + te[7] = a41 * b12 + a42 * b22 + a43 * b32 + a44 * b42; + te[11] = a41 * b13 + a42 * b23 + a43 * b33 + a44 * b43; + te[15] = a41 * b14 + a42 * b24 + a43 * b34 + a44 * b44; + return this; + } + + compose(position: Vector3, quaternion: Quaternion, scale: Vector3): Matrix4 { + const te = this.elements; + const x = quaternion.x, + y = quaternion.y, + z = quaternion.z, + w = quaternion.w, + x2 = x + x, + y2 = y + y, + z2 = z + z, + xx = x * x2, + xy = x * y2, + xz = x * z2, + yy = y * y2, + yz = y * z2, + zz = z * z2, + wx = w * x2, + wy = w * y2, + wz = w * z2, + sx = scale.x, + sy = scale.y, + sz = scale.z; + + te[0] = (1 - (yy + zz)) * sx; + te[1] = (xy + wz) * sx; + te[2] = (xz - wy) * sx; + te[3] = 0; + + te[4] = (xy - wz) * sy; + te[5] = (1 - (xx + zz)) * sy; + te[6] = (yz + wx) * sy; + te[7] = 0; + + te[8] = (xz + wy) * sz; + te[9] = (yz - wx) * sz; + te[10] = (1 - (xx + yy)) * sz; + te[11] = 0; + + te[12] = position.x; + te[13] = position.y; + te[14] = position.z; + te[15] = 1; + return this; + } + + decompose(position: Vector3, quaternion: Quaternion, scale: Vector3): this { + const te = this.elements; + + let sx = tmpVec.set(te[0], te[1], te[2]).length(); + const sy = tmpVec.set(te[4], te[5], te[6]).length(); + const sz = tmpVec.set(te[8], te[9], te[10]).length(); + const det = this.determinant(); + if (det < 0) { + sx *= -1; + } + + position.set(te[12], te[13], te[14]); + + const invSX = 1 / sx; + const invSY = 1 / sy; + const invSZ = 1 / sz; + + tmpMat4.set(this.elements); + tmpMat4.elements[0] *= invSX; + tmpMat4.elements[1] *= invSX; + tmpMat4.elements[2] *= invSX; + + tmpMat4.elements[4] *= invSY; + tmpMat4.elements[5] *= invSY; + tmpMat4.elements[6] *= invSY; + + tmpMat4.elements[8] *= invSZ; + tmpMat4.elements[9] *= invSZ; + tmpMat4.elements[10] *= invSZ; + + quaternion.setRotationMatrix(tmpMat4); + scale.set(sx, sy, sz); + + return this; + } + + transpose(): this { + const te = this.elements; + let tmp: number; + tmp = te[1]; + te[1] = te[4]; + te[4] = tmp; + tmp = te[2]; + te[2] = te[8]; + te[8] = tmp; + tmp = te[6]; + te[6] = te[9]; + te[9] = tmp; + tmp = te[3]; + te[3] = te[12]; + te[12] = tmp; + tmp = te[7]; + te[7] = te[13]; + te[13] = tmp; + tmp = te[11]; + te[11] = te[14]; + te[14] = tmp; + return this; + } +} + +export class Matrix3 { + elements = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + set( + n11: number, + n12: number, + n13: number, + n21: number, + n22: number, + n23: number, + n31: number, + n32: number, + n33: number, + ): this { + const te = this.elements; + te[0] = n11; + te[1] = n21; + te[2] = n31; + te[3] = n12; + te[4] = n22; + te[5] = n32; + te[6] = n13; + te[7] = n23; + te[8] = n33; + return this; + } + + setFromMatrix4(m: Matrix4): this { + const me = m.elements; + this.set(me[0], me[4], me[8], me[1], me[5], me[9], me[2], me[6], me[10]); + return this; + } +} + +const tmpVec = new Vector3(0, 0, 0); +const tmpMat4 = new Matrix4(); diff --git a/packages/splat-transform/src/utils/quantize1d.ts b/packages/splat-transform/src/utils/quantize1d.ts new file mode 100644 index 0000000..80ae0b4 --- /dev/null +++ b/packages/splat-transform/src/utils/quantize1d.ts @@ -0,0 +1,194 @@ +// https://github.com/playcanvas/splat-transform/blob/main/src/lib/spatial/quantize-1d.ts +export function quantize1d(fields: Float32Array[], k = 256, alpha = 0.5, transform?: (v: number) => number) { + const numColumns = fields.length; + const numRows = fields.length > 0 ? fields[0].length : 0; + + // pool all columns into a flat 1D array + const N = numRows * numColumns; + + if (N === 0) { + const centroids = new Float32Array(k); + const labels = fields.map(_ => new Uint8Array(numRows)); + return { centroids, labels }; + } + + const data = new Float32Array(N); + for (let i = 0; i < numColumns; ++i) { + data.set(fields[i], i * numRows); + } + if (transform) { + for (let i = 0; i < data.length; i++) { + data[i] = transform(data[i]); + } + } + + // sort a copy for histogram binning (keep original for label assignment) + const sortedData = new Float32Array(data); + sortedData.sort(); + + const vMin = sortedData[0]; + const vMax = sortedData[N - 1]; + + // handle degenerate case where all values are identical + if (vMax - vMin < 1e-20) { + const centroids = new Float32Array(k); + centroids.fill(vMin); + + const labels = fields.map(_ => new Uint8Array(numRows)); + return { centroids, labels }; + } + + // build histogram using blended uniform/quantile bin positions + const H = Math.min(1024, N); + const vRange = vMax - vMin; + + // adaptive blend ratio: when outliers are extreme (IQR << range), lean + // strongly toward quantile to give the dense center adequate bins; when + // the distribution has moderate tails (IQR ~ range), reduce quantile + // bias somewhat, but keep at least 50% quantile to preserve density + const iqr = sortedData[Math.floor(N * 0.75)] - sortedData[Math.floor(N * 0.25)]; + const beta = Math.max(0.5, Math.min(0.999, 1 - iqr / vRange)); + + const counts = new Float64Array(H); + const sums = new Float64Array(H); + + for (let i = 0; i < N; ++i) { + const uniformPos = (sortedData[i] - vMin) / vRange; + const quantilePos = i / N; + const bin = Math.min(H - 1, Math.floor(H * (beta * quantilePos + (1 - beta) * uniformPos))); + counts[bin]++; + sums[bin] += sortedData[i]; + } + + const centers = new Float64Array(H); + for (let i = 0; i < H; ++i) { + centers[i] = counts[i] > 0 ? sums[i] / counts[i] : vMin + ((i + 0.5) / H) * vRange; + } + + // compute weights: w = count^alpha (sub-linear density weighting) + const weights = new Float64Array(H); + for (let i = 0; i < H; ++i) { + weights[i] = counts[i] > 0 ? Math.pow(counts[i], alpha) : 0; + } + + // prefix sums for O(1) range cost queries + // cost(a,b) = sum_wxx - sum_wx^2 / sum_w + // centroid(a,b) = sum_wx / sum_w + const prefW = new Float64Array(H + 1); + const prefWX = new Float64Array(H + 1); + const prefWXX = new Float64Array(H + 1); + for (let i = 0; i < H; ++i) { + prefW[i + 1] = prefW[i] + weights[i]; + prefWX[i + 1] = prefWX[i] + weights[i] * centers[i]; + prefWXX[i + 1] = prefWXX[i] + weights[i] * centers[i] * centers[i]; + } + + const rangeCost = (a: number, b: number): number => { + const w = prefW[b + 1] - prefW[a]; + if (w <= 0) { + return 0; + } + const wx = prefWX[b + 1] - prefWX[a]; + const wxx = prefWXX[b + 1] - prefWXX[a]; + return wxx - (wx * wx) / w; + }; + + const rangeMean = (a: number, b: number): number => { + const w = prefW[b + 1] - prefW[a]; + if (w <= 0) { + return (centers[a] + centers[b]) * 0.5; + } + return (prefWX[b + 1] - prefWX[a]) / w; + }; + + const nonEmpty = counts.reduce((n, c) => n + (c > 0 ? 1 : 0), 0); + const effectiveK = Math.min(k, nonEmpty); + + // DP: dp[m][j] = min weighted SSE of quantizing bins 0..j into m centroids + // Use two rows to save memory (only need previous row) + const INF = 1e30; + let dpPrev = new Float64Array(H).fill(INF); + let dpCurr = new Float64Array(H).fill(INF); + const splitTable = new Array(effectiveK + 1); + + // base case: m = 1 + const split1 = new Int32Array(H); + for (let j = 0; j < H; ++j) { + dpPrev[j] = rangeCost(0, j); + split1[j] = -1; + } + splitTable[1] = split1; + + // fill DP for m = 2..effectiveK + for (let m = 2; m <= effectiveK; ++m) { + dpCurr.fill(INF); + const splitM = new Int32Array(H); + + for (let j = m - 1; j < H; ++j) { + let bestCost = INF; + let bestS = m - 2; + + for (let s = m - 2; s < j; ++s) { + const cost = dpPrev[s] + rangeCost(s + 1, j); + if (cost < bestCost) { + bestCost = cost; + bestS = s; + } + } + + dpCurr[j] = bestCost; + splitM[j] = bestS; + } + + splitTable[m] = splitM; + + // swap rows + const tmp = dpPrev; + dpPrev = dpCurr; + dpCurr = tmp; + } + + // backtrack to find centroid values + const centroidValues = new Float32Array(effectiveK); + let j = H - 1; + for (let m = effectiveK; m >= 1; --m) { + const s = m > 1 ? splitTable[m][j] : -1; + centroidValues[m - 1] = rangeMean(s + 1, j); + j = s; + } + + // sort centroids (should already be sorted, but ensure) + centroidValues.sort(); + + // pad to k entries if effectiveK < k (duplicate last centroid) + const finalCentroids = new Float32Array(k); + finalCentroids.set(centroidValues); + for (let i = effectiveK; i < k; ++i) { + finalCentroids[i] = centroidValues[effectiveK - 1]; + } + + // assign each data point to nearest centroid via binary search + const labels = new Uint8Array(N); + for (let i = 0; i < N; ++i) { + const v = data[i]; + + // binary search for nearest centroid + let lo = 0; + let hi = k - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + // compare against midpoint between centroids mid and mid+1 + if (v < (finalCentroids[mid] + finalCentroids[mid + 1]) * 0.5) { + hi = mid; + } else { + lo = mid + 1; + } + } + labels[i] = lo; + } + + return { + labels: fields.map((_, i) => labels.subarray(i * numRows, (i + 1) * numRows)), + centroids: finalCentroids, + }; +} diff --git a/packages/splat-transform/src/utils/shRotate.ts b/packages/splat-transform/src/utils/shRotate.ts new file mode 100644 index 0000000..ea9d524 --- /dev/null +++ b/packages/splat-transform/src/utils/shRotate.ts @@ -0,0 +1,246 @@ +import type { Matrix3 } from './math.js'; + +/** + * 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. + */ +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); +export function createSHRotateFn(mat: Matrix3) { + const rot = mat.elements; + + const sh1 = [ + [rot[4], -rot[7], rot[1]], + [-rot[5], rot[8], -rot[2]], + [rot[3], -rot[6], rot[0]], + ]; + + 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])), + ], + ]; + + 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])), + ], + ]; + + const dp = (n: number, start: number, a: Float32Array, b: number[]) => { + let sum = 0; + for (let i = 0; i < n; i++) { + sum += a[start + i] * b[i]; + } + return sum; + }; + + const temp = new Float32Array(15); + return (shN: number[]) => { + temp.set(shN); + + if (shN.length < 3) { + return; + } + shN[0] = dp(3, 0, temp, sh1[0]); + shN[1] = dp(3, 0, temp, sh1[1]); + shN[2] = dp(3, 0, temp, sh1[2]); + + if (shN.length < 8) { + return; + } + shN[3] = dp(5, 3, temp, sh2[0]); + shN[4] = dp(5, 3, temp, sh2[1]); + shN[5] = dp(5, 3, temp, sh2[2]); + shN[6] = dp(5, 3, temp, sh2[3]); + shN[7] = dp(5, 3, temp, sh2[4]); + + if (shN.length < 15) { + return; + } + shN[8] = dp(7, 8, temp, sh3[0]); + shN[9] = dp(7, 8, temp, sh3[1]); + shN[10] = dp(7, 8, temp, sh3[2]); + shN[11] = dp(7, 8, temp, sh3[3]); + shN[12] = dp(7, 8, temp, sh3[4]); + shN[13] = dp(7, 8, temp, sh3[5]); + shN[14] = dp(7, 8, temp, sh3[6]); + }; +} diff --git a/packages/splat-transform/src/utils/splat.ts b/packages/splat-transform/src/utils/splat.ts new file mode 100644 index 0000000..e0b4c63 --- /dev/null +++ b/packages/splat-transform/src/utils/splat.ts @@ -0,0 +1,467 @@ +import fs from 'node:fs'; +import { Writable } from 'node:stream'; +import { unzipSync } from 'fflate'; +import { type IFile, PlyFile, SpzFile, KsplatFile, SplatFile, SogFile, LccFile, EszFile } from '../file/index.js'; +import { ColIdx, type ISingleSplat, SplatData } from '../SplatData.js'; +import { SH_MAPS } from '../constant.js'; + +export interface ISplatData { + counts: number; + shDegree: number; + table: Float32Array[]; +} + +export enum SplatFileType { + PLY, + SPZ, + USPZ, // not gzip spz + SPLAT, + KSPLAT, + SOG, + LCC, + ESZ, +} + +export function detectSplatFileType(filename: string, buffer: Uint8Array = new Uint8Array()) { + let ext = filename.split('.').pop(); + if (ext === 'zip') { + unzipSync(buffer, { + filter: file => { + const { name } = file; + if (name.endsWith('meta.json')) { + ext = 'sog'; + } else if (name.endsWith('meta.lcc')) { + ext = 'lcc'; + } + return false; + }, + }); + } else if (ext === 'json') { + // fast check sog json + const json = JSON.parse(new TextDecoder().decode(buffer)); + const isSogMetadata = ['means', 'scales', 'quats', 'sh0'].every(k => !!json[k]); + if (isSogMetadata) { + ext = 'sog'; + } + } + + let type: SplatFileType | undefined; + switch (ext) { + case 'ply': { + type = SplatFileType.PLY; + break; + } + case 'spz': { + type = SplatFileType.SPZ; + break; + } + case 'uspz': { + type = SplatFileType.USPZ; + break; + } + case 'splat': { + type = SplatFileType.SPLAT; + break; + } + case 'ksplat': { + type = SplatFileType.KSPLAT; + break; + } + case 'sog': { + type = SplatFileType.SOG; + break; + } + case 'lcc': { + type = SplatFileType.LCC; + break; + } + case 'esz': { + type = SplatFileType.ESZ; + break; + } + default: { + break; + } + } + return type; +} + +export function createSplatFile( + path: string, + buffer: Uint8Array = new Uint8Array(), + compressLevel: number = 6, + spzVersion: number = 3, +) { + const type = detectSplatFileType(path, buffer); + if (type === undefined) { + throw new Error(`Unsupported file format: ${path}`); + } + let file: IFile; + switch (type) { + case SplatFileType.PLY: { + file = new PlyFile(); + break; + } + case SplatFileType.SPZ: { + file = new SpzFile(compressLevel, spzVersion); + break; + } + case SplatFileType.USPZ: { + file = new SpzFile(-1); + break; + } + case SplatFileType.KSPLAT: { + file = new KsplatFile(); + break; + } + case SplatFileType.SPLAT: { + file = new SplatFile(); + break; + } + case SplatFileType.SOG: { + file = new SogFile(); + break; + } + case SplatFileType.LCC: { + file = new LccFile(); + break; + } + case SplatFileType.ESZ: { + file = new EszFile(); + break; + } + } + return file; +} + +export async function writeSplatFile( + filepath: string, + data: SplatData, + enableMortonSort: boolean, + compressLevel?: number, + spzVersion?: number, +) { + let indices: Uint32Array | undefined; + if (!enableMortonSort) { + indices = new Uint32Array(data.counts); + for (let i = 0; i < data.counts; i++) { + indices[i] = i; + } + } + const file = createSplatFile(filepath, undefined, compressLevel, spzVersion); + const stream = Writable.toWeb(fs.createWriteStream(filepath)) as WritableStream; + await file.write(stream, data, indices); +} + +export function combineSplatData(source: SplatData[]): SplatData { + const target = new SplatData().init( + source.reduce((p, c) => p + c.counts, 0), + Math.max(...source.map(v => v.shDegree)), + ); + const single: ISingleSplat = { + x: 0, + y: 0, + z: 0, + sx: 0, + sy: 0, + sz: 0, + qx: 0, + qy: 0, + qz: 0, + qw: 0, + r: 0, + g: 0, + b: 0, + a: 0, + shN: new Array(SH_MAPS[target.shDegree]), + }; + const shN = single.shN; + + let index = 0; + for (let i = 0; i < source.length; i++) { + const splat = source[i]; + const { counts } = splat; + for (let j = 0; j < counts; j++) { + splat.get(j, single); + splat.getShN(j, shN); + target.set(index, single); + target.setShN(index, shN); + index++; + } + } + + return target; +} + +const VOXEL_COUNTS = 65535; +export function computeDenseBox(data: SplatData, ratio: number = 0.98) { + if (data.counts === 0) { + return { min: [0, 0, 0], max: [0, 0, 0] }; + } + + const xCol = data.table[ColIdx.x]; + const yCol = data.table[ColIdx.y]; + const zCol = data.table[ColIdx.z]; + + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + for (let i = 0; i < data.counts; i++) { + const x = xCol[i]; + const y = yCol[i]; + const z = zCol[i]; + if (x < minX) { + minX = x; + } + if (x > maxX) { + maxX = x; + } + if (y < minY) { + minY = y; + } + if (y > maxY) { + maxY = y; + } + if (z < minZ) { + minZ = z; + } + if (z > maxZ) { + maxZ = z; + } + } + + const scaleX = VOXEL_COUNTS / Math.max(maxX - minX, 1e-9); + const scaleY = VOXEL_COUNTS / Math.max(maxY - minY, 1e-9); + const scaleZ = VOXEL_COUNTS / Math.max(maxZ - minZ, 1e-9); + + const xChunks = new Uint32Array(VOXEL_COUNTS); + const yChunks = new Uint32Array(VOXEL_COUNTS); + const zChunks = new Uint32Array(VOXEL_COUNTS); + for (let i = 0; i < data.counts; i++) { + xChunks[((xCol[i] - minX) * scaleX) | 0]++; + yChunks[((yCol[i] - minY) * scaleY) | 0]++; + zChunks[((zCol[i] - minZ) * scaleZ) | 0]++; + } + + const K = Math.ceil(data.counts * (1 - ratio)); + let startX = 0; + let endX = VOXEL_COUNTS - 1; + let startY = 0; + let endY = VOXEL_COUNTS - 1; + let startZ = 0; + let endZ = VOXEL_COUNTS - 1; + let count = data.counts; + while (count > K) { + const xs = xChunks[startX]; + const xe = xChunks[endX]; + const ys = yChunks[startY]; + const ye = yChunks[endY]; + const zs = zChunks[startZ]; + const ze = zChunks[endZ]; + + let min = xs; + let minKey = 'startX'; + if (xe < min) { + min = xe; + minKey = 'endX'; + } + if (ys < min) { + min = ys; + minKey = 'startY'; + } + if (ye < min) { + min = ye; + minKey = 'endY'; + } + if (zs < min) { + min = zs; + minKey = 'startZ'; + } + if (ze < min) { + min = ze; + minKey = 'endZ'; + } + switch (minKey) { + case 'startX': + startX++; + break; + case 'endX': + endX--; + break; + case 'startY': + startY++; + break; + case 'endY': + endY--; + break; + case 'startZ': + startZ++; + break; + case 'endZ': + endZ--; + break; + } + count -= min; + } + + return { + min: [startX / scaleX + minX, startY / scaleY + minY, startZ / scaleZ + minZ], + max: [endX / scaleX + minX, endY / scaleY + minY, endZ / scaleZ + minZ], + }; +} + +// https://github.com/playcanvas/splat-transform/blob/main/src/lib/data-table/data-table.ts +export function mortonSort(splat: SplatData): Uint32Array { + const result = new Uint32Array(splat.counts); + const xCol = splat.table[ColIdx.x]; + const yCol = splat.table[ColIdx.y]; + const zCol = splat.table[ColIdx.z]; + + for (let i = 0; i < result.length; ++i) { + result[i] = i; + } + + const generate = (indices: Uint32Array) => { + if (indices.length === 0) { + return; + } + + // 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 mx = Infinity; + let my = Infinity; + let mz = Infinity; + let Mx = -Infinity; + let My = -Infinity; + let Mz = -Infinity; + + // calculate scene extents across all splats (using sort centers, because they're in world space) + for (let i = 0; i < indices.length; ++i) { + const ri = indices[i]; + const x = xCol[ri]; + const y = yCol[ri]; + const z = zCol[ri]; + + if (x < mx) { + mx = x; + } + if (x > Mx) { + Mx = x; + } + if (y < my) { + my = y; + } + if (y > My) { + My = y; + } + if (z < mz) { + mz = z; + } + if (z > Mz) { + Mz = z; + } + } + + const xlen = Mx - mx; + const ylen = My - my; + const zlen = Mz - mz; + + if (!isFinite(xlen) || !isFinite(ylen) || !isFinite(zlen)) { + console.debug('invalid extents', xlen, ylen, zlen); + return; + } + + // all points are identical + if (xlen === 0 && ylen === 0 && zlen === 0) { + return; + } + + const xmul = xlen === 0 ? 0 : 1024 / xlen; + const ymul = ylen === 0 ? 0 : 1024 / ylen; + const zmul = zlen === 0 ? 0 : 1024 / zlen; + + const morton = new Uint32Array(indices.length); + for (let i = 0; i < indices.length; ++i) { + const ri = indices[i]; + const x = xCol[ri]; + const y = yCol[ri]; + const z = zCol[ri]; + + const ix = Math.min(1023, (x - mx) * xmul) >>> 0; + const iy = Math.min(1023, (y - my) * ymul) >>> 0; + const iz = Math.min(1023, (z - mz) * zmul) >>> 0; + + morton[i] = encodeMorton3(ix, iy, iz); + } + + // sort indices by morton code + const order = new Uint32Array(indices.length); + for (let i = 0; i < order.length; i++) { + order[i] = i; + } + order.sort((a, b) => morton[a] - morton[b]); + + const tmpIndices = indices.slice(); + for (let i = 0; i < indices.length; ++i) { + indices[i] = tmpIndices[order[i]]; + } + + // sort the largest buckets recursively + let start = 0; + let end = 1; + while (start < indices.length) { + while (end < indices.length && morton[order[end]] === morton[order[start]]) { + ++end; + } + + if (end - start > 256) { + generate(indices.subarray(start, end)); + } + + start = end; + } + }; + + generate(result); + return result; +} + +export function fastDeleteSplat(splat: SplatData, indices: number[]) { + const { counts, table } = splat; + const map = new Uint32Array(counts - indices.length); + let write = 0; + let removeIdx = 0; + for (let read = 0; read < counts; read++) { + if (removeIdx < indices.length && read === indices[removeIdx]) { + removeIdx++; + continue; + } + + map[write++] = read; + } + for (let i = 0; i < table.length; i++) { + const col = table[i]; + for (let j = 0; j < map.length; j++) { + col[j] = col[map[j]]; + } + } + splat.counts -= indices.length; + for (let i = 0; i < table.length; i++) { + table[i] = table[i].subarray(0, splat.counts); + } +} diff --git a/packages/splat-transform/src/utils/voxel/binary.ts b/packages/splat-transform/src/utils/voxel/binary.ts new file mode 100644 index 0000000..da87fe1 --- /dev/null +++ b/packages/splat-transform/src/utils/voxel/binary.ts @@ -0,0 +1,199 @@ +import { popcount } from './common.js'; + +const SOLID_LEAF_MARKER = 0xff000000 >>> 0; +const COMPACT_MAGIC = 0x31424356; // "VCB1" little-endian +const COMPACT_VERSION = 1; +const HEADER_UINT32_COUNT = 8; +const HEADER_BYTES = HEADER_UINT32_COUNT * 4; + +const TAG_MIXED = 0; +const TAG_SOLID = 1; +const TAG_INTERNAL = 2; + +export type VoxelNodeEncoding = 'raw' | 'compact'; + +export interface DecodedVoxelBinary { + nodes: Uint32Array; + leafData: Uint32Array; +} + +function getTagByteLength(nodeCount: number): number { + return Math.ceil(nodeCount / 4); +} + +function align4(value: number): number { + return (value + 3) & ~3; +} + +function writeTag(tags: Uint8Array, index: number, tag: number): void { + const shift = (index & 3) << 1; + tags[index >> 2] = (tags[index >> 2] & ~(0x3 << shift)) | ((tag & 0x3) << shift); +} + +function readTag(tags: Uint8Array, index: number): number { + const shift = (index & 3) << 1; + return (tags[index >> 2] >>> shift) & 0x3; +} + +function toUint8Array(data: Uint8Array): Uint8Array { + return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength + ? data + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); +} + +export function encodeRawVoxelBinary(nodes: Uint32Array, leafData: Uint32Array): Uint8Array { + const binary = new Uint8Array((nodes.length + leafData.length) * 4); + const view = new Uint32Array(binary.buffer); + view.set(nodes, 0); + view.set(leafData, nodes.length); + return binary; +} + +export function encodeCompactVoxelBinary( + nodes: Uint32Array, + leafData: Uint32Array, + numInteriorNodes: number, + numMixedLeaves: number, +): Uint8Array { + const nodeCount = nodes.length; + const tagBytes = getTagByteLength(nodeCount); + const tags = new Uint8Array(tagBytes); + const childMasks = new Uint8Array(numInteriorNodes); + let interiorCursor = 0; + let mixedCursor = 0; + + for (let i = 0; i < nodeCount; i++) { + const node = nodes[i] >>> 0; + if (node === SOLID_LEAF_MARKER) { + writeTag(tags, i, TAG_SOLID); + continue; + } + const childMask = node >>> 24; + if (childMask === 0) { + const leafDataIndex = node & 0x00ffffff; + if (leafDataIndex !== mixedCursor) { + throw new Error( + `compact voxel encoding requires mixed leaf indices to be BFS-sequential: ` + + `node ${i} has ${leafDataIndex}, expected ${mixedCursor}`, + ); + } + writeTag(tags, i, TAG_MIXED); + mixedCursor++; + continue; + } + if (childMask === 0xff && (node & 0x00ffffff) === 0) { + throw new Error(`invalid voxel node ${i}: solid sentinel must be encoded as 0xFF000000`); + } + writeTag(tags, i, TAG_INTERNAL); + if (interiorCursor >= childMasks.length) { + throw new Error(`compact voxel encoding found more interior nodes than metadata (${numInteriorNodes})`); + } + childMasks[interiorCursor++] = childMask; + } + + if (interiorCursor !== numInteriorNodes) { + throw new Error(`compact voxel encoding interior count mismatch: ${interiorCursor} !== ${numInteriorNodes}`); + } + if (mixedCursor !== numMixedLeaves) { + throw new Error(`compact voxel encoding mixed leaf count mismatch: ${mixedCursor} !== ${numMixedLeaves}`); + } + + const leafBytes = toUint8Array(new Uint8Array(leafData.buffer, leafData.byteOffset, leafData.byteLength)); + const leafOffset = align4(HEADER_BYTES + tagBytes + childMasks.byteLength); + const binary = new Uint8Array(leafOffset + leafBytes.byteLength); + const header = new DataView(binary.buffer, 0, HEADER_BYTES); + header.setUint32(0, COMPACT_MAGIC, true); + header.setUint32(4, COMPACT_VERSION, true); + header.setUint32(8, nodeCount, true); + header.setUint32(12, numInteriorNodes, true); + header.setUint32(16, numMixedLeaves, true); + header.setUint32(20, leafData.length, true); + header.setUint32(24, tagBytes, true); + header.setUint32(28, childMasks.byteLength, true); + binary.set(tags, HEADER_BYTES); + binary.set(childMasks, HEADER_BYTES + tagBytes); + binary.set(leafBytes, leafOffset); + return binary; +} + +export function decodeCompactVoxelBinary(binary: Uint8Array): DecodedVoxelBinary { + if (binary.byteLength < HEADER_BYTES) { + throw new Error('compact voxel binary is too small for header'); + } + const header = new DataView(binary.buffer, binary.byteOffset, HEADER_BYTES); + const magic = header.getUint32(0, true); + const version = header.getUint32(4, true); + if (magic !== COMPACT_MAGIC) { + throw new Error(`invalid compact voxel magic 0x${magic.toString(16)}`); + } + if (version !== COMPACT_VERSION) { + throw new Error(`unsupported compact voxel version ${version}`); + } + const nodeCount = header.getUint32(8, true); + const numInteriorNodes = header.getUint32(12, true); + const numMixedLeaves = header.getUint32(16, true); + const leafDataCount = header.getUint32(20, true); + const tagBytes = header.getUint32(24, true); + const childMaskBytes = header.getUint32(28, true); + if (tagBytes !== getTagByteLength(nodeCount)) { + throw new Error(`compact voxel tag byte count mismatch: ${tagBytes} !== ${getTagByteLength(nodeCount)}`); + } + if (childMaskBytes !== numInteriorNodes) { + throw new Error(`compact voxel child-mask byte count mismatch: ${childMaskBytes} !== ${numInteriorNodes}`); + } + const leafOffset = align4(HEADER_BYTES + tagBytes + childMaskBytes); + const expectedBytes = leafOffset + leafDataCount * 4; + if (binary.byteLength !== expectedBytes) { + throw new Error(`compact voxel byte length mismatch: ${binary.byteLength} !== ${expectedBytes}`); + } + + const tags = new Uint8Array(binary.buffer, binary.byteOffset + HEADER_BYTES, tagBytes); + const childMasks = new Uint8Array(binary.buffer, binary.byteOffset + HEADER_BYTES + tagBytes, childMaskBytes); + const leafData = new Uint32Array(leafDataCount); + leafData.set(new Uint32Array(binary.buffer, binary.byteOffset + leafOffset, leafDataCount)); + + const nodes = new Uint32Array(nodeCount); + let levelStart = 0; + let levelEnd = nodeCount > 0 ? 1 : 0; + let childStart = levelEnd; + let interiorCursor = 0; + let mixedCursor = 0; + + while (levelStart < levelEnd) { + let nextLevelEnd = childStart; + for (let i = levelStart; i < levelEnd; i++) { + const tag = readTag(tags, i); + if (tag === TAG_SOLID) { + nodes[i] = SOLID_LEAF_MARKER; + } else if (tag === TAG_MIXED) { + nodes[i] = mixedCursor++; + } else if (tag === TAG_INTERNAL) { + if (interiorCursor >= childMasks.length) { + throw new Error('compact voxel decode exhausted child masks'); + } + const childMask = childMasks[interiorCursor++]; + nodes[i] = ((childMask << 24) | childStart) >>> 0; + childStart += popcount(childMask); + nextLevelEnd = childStart; + if (childStart > nodeCount) { + throw new Error(`compact voxel child range exceeds node count (${childStart} > ${nodeCount})`); + } + } else { + throw new Error(`compact voxel decode found reserved node tag ${tag} at node ${i}`); + } + } + levelStart = levelEnd; + levelEnd = nextLevelEnd; + } + + if (levelEnd !== nodeCount) { + throw new Error(`compact voxel decode did not consume all nodes: ${levelEnd} !== ${nodeCount}`); + } + if (interiorCursor !== numInteriorNodes) { + throw new Error(`compact voxel decode interior count mismatch: ${interiorCursor} !== ${numInteriorNodes}`); + } + if (mixedCursor !== numMixedLeaves) { + throw new Error(`compact voxel decode mixed leaf count mismatch: ${mixedCursor} !== ${numMixedLeaves}`); + } + return { nodes, leafData }; +} diff --git a/packages/splat-transform/src/utils/voxel/common.ts b/packages/splat-transform/src/utils/voxel/common.ts new file mode 100644 index 0000000..525a1e5 --- /dev/null +++ b/packages/splat-transform/src/utils/voxel/common.ts @@ -0,0 +1,2074 @@ +/** 3D Morton (Z-order) for integer block coordinates. */ +export function encodeMorton3(x: number, y: number, z: number): number { + let result = 0; + let shift = 1; + for (let i = 0; i < 17; i++) { + if (x & 1) { + result += shift; + } + if (y & 1) { + result += shift * 2; + } + if (z & 1) { + result += shift * 4; + } + x >>>= 1; + y >>>= 1; + z >>>= 1; + shift *= 8; + } + return result; +} + +export function decodeMorton3(m: number): [number, number, number] { + let x = 0, + y = 0, + z = 0; + let bit = 1; + while (m > 0) { + const triplet = m % 8; + if (triplet & 1) { + x |= bit; + } + if (triplet & 2) { + y |= bit; + } + if (triplet & 4) { + z |= bit; + } + bit <<= 1; + m = Math.trunc(m / 8); + } + return [x, y, z]; +} + +export interface Bounds { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; +} + +/** Voxel leaf edge length in voxels (4³ block). */ +export const LEAF_SIZE = 4; +export const ALPHA_THRESHOLD = 1 / 255; + +export function alignGridBounds(bounds: Bounds, voxelResolution: number): Bounds { + const blockSize = LEAF_SIZE * voxelResolution; + return { + min: { + x: Math.floor(bounds.min.x / blockSize) * blockSize, + y: Math.floor(bounds.min.y / blockSize) * blockSize, + z: Math.floor(bounds.min.z / blockSize) * blockSize, + }, + max: { + x: Math.ceil(bounds.max.x / blockSize) * blockSize, + y: Math.ceil(bounds.max.y / blockSize) * blockSize, + z: Math.ceil(bounds.max.z / blockSize) * blockSize, + }, + }; +} + +/** + * Max linear block index (signed 32-bit): BlockMaskMap keys, worker `blockIdx >>> 0`, + * and types bitmap indexing. Implies types bitmap <= ceil(MAX / 16) * 4 ~= 512 MB. + */ +export const MAX_VOXEL_BLOCK_COUNT_INT32 = 0x7fff_ffff; + +/** Preflight hard grid limits before voxelization allocates the types bitmap. */ +export function checkVoxelGridCapacity(gridBounds: Bounds, voxelResolution: number): void { + const blockSize = LEAF_SIZE * voxelResolution; + const nbx = Math.round((gridBounds.max.x - gridBounds.min.x) / blockSize); + const nby = Math.round((gridBounds.max.y - gridBounds.min.y) / blockSize); + const nbz = Math.round((gridBounds.max.z - gridBounds.min.z) / blockSize); + const totalBlocks = nbx * nby * nbz; + if (totalBlocks > MAX_VOXEL_BLOCK_COUNT_INT32) { + throw new Error( + `voxel grid block count ${totalBlocks} exceeds limit ${MAX_VOXEL_BLOCK_COUNT_INT32} ` + + `(linear block index must fit in 31 bits). ` + + `Tighten the box parameter or increase voxelResolution. grid blocks=${nbx}x${nby}x${nbz}`, + ); + } +} + +/** Opacity-aware AABB half-extents from scale + unit quaternion. */ +export function extentsFromQuatScale( + sx: number, + sy: number, + sz: number, + qx: number, + qy: number, + qz: number, + qw: number, + opacity?: number, + opacityThreshold = ALPHA_THRESHOLD, +) { + let extend = 3; + if (opacity !== undefined && opacity > opacityThreshold) { + // Tight bound from opacity threshold, clamped by default 3-sigma bound. + const opacityAware = Math.sqrt(2 * Math.log(opacity / opacityThreshold)); + if (Number.isFinite(opacityAware)) { + extend = Math.min(extend, opacityAware); + } + } else if (opacity !== undefined && opacity <= opacityThreshold) { + return { ex: 0, ey: 0, ez: 0 }; + } + + const sX = extend * sx; + const sY = extend * sy; + const sZ = extend * sz; + + const xx = qx * qx; + const yy = qy * qy; + const zz = qz * qz; + const xy = qx * qy; + const xz = qx * qz; + const yz = qy * qz; + const wx = qw * qx; + const wy = qw * qy; + const wz = qw * qz; + + const m00 = 1 - 2 * (yy + zz); + const m01 = 2 * (xy - wz); + const m02 = 2 * (xz + wy); + const m10 = 2 * (xy + wz); + const m11 = 1 - 2 * (xx + zz); + const m12 = 2 * (yz - wx); + const m20 = 2 * (xz - wy); + const m21 = 2 * (yz + wx); + const m22 = 1 - 2 * (xx + yy); + + const abs00 = Math.abs(m00); + const abs01 = Math.abs(m01); + const abs02 = Math.abs(m02); + const abs10 = Math.abs(m10); + const abs11 = Math.abs(m11); + const abs12 = Math.abs(m12); + const abs20 = Math.abs(m20); + const abs21 = Math.abs(m21); + const abs22 = Math.abs(m22); + const ex = abs00 * sX + abs01 * sY + abs02 * sZ; + const ey = abs10 * sX + abs11 * sY + abs12 * sZ; + const ez = abs20 * sX + abs21 * sY + abs22 * sZ; + + return { ex, ey, ez }; +} + +interface BVHBounds { + minX: number; + minY: number; + minZ: number; + maxX: number; + maxY: number; + maxZ: number; +} +interface BVHNode { + bounds: BVHBounds; + indices?: Uint32Array; + left?: BVHNode; + right?: BVHNode; +} + +function boundsOverlap( + a: BVHBounds, + bMinX: number, + bMinY: number, + bMinZ: number, + bMaxX: number, + bMaxY: number, + bMaxZ: number, +) { + return !(a.maxX < bMinX || a.minX > bMaxX || a.maxY < bMinY || a.minY > bMaxY || a.maxZ < bMinZ || a.minZ > bMaxZ); +} + +function quickselect(axisData: Float32Array, idx: Uint32Array, k: number): number { + function valAt(p: number) { + return axisData[idx[p]]; + } + function swap(i: number, j: number) { + const t = idx[i]; + idx[i] = idx[j]; + idx[j] = t; + } + const n = idx.length; + let l = 0; + let r = n - 1; + + while (true) { + if (r <= l + 1) { + if (r === l + 1 && valAt(r) < valAt(l)) { + swap(l, r); + } + return idx[k]; + } + + const mid = (l + r) >>> 1; + swap(mid, l + 1); + if (valAt(l) > valAt(r)) { + swap(l, r); + } + if (valAt(l + 1) > valAt(r)) { + swap(l + 1, r); + } + if (valAt(l) > valAt(l + 1)) { + swap(l, l + 1); + } + + let i = l + 1; + let j = r; + const pivotIdxVal = valAt(l + 1); + const pivotIdx = idx[l + 1]; + + while (true) { + do { + i++; + } while (i <= r && valAt(i) < pivotIdxVal); + do { + j--; + } while (j >= l && valAt(j) > pivotIdxVal); + if (j < i) { + break; + } + swap(i, j); + } + + idx[l + 1] = idx[j]; + idx[j] = pivotIdx; + + if (j >= k) { + r = j - 1; + } + if (j <= k) { + l = i; + } + } +} + +export class GaussianBVH { + private static readonly MAX_LEAF_SIZE = 64; + private readonly x: Float32Array; + private readonly y: Float32Array; + private readonly z: Float32Array; + private readonly extents: Float32Array; + private readonly root: BVHNode; + constructor(x: Float32Array, y: Float32Array, z: Float32Array, extents: Float32Array) { + this.x = x; + this.y = y; + this.z = z; + this.extents = extents; + const indices = new Uint32Array(x.length); + for (let i = 0; i < x.length; i++) { + indices[i] = i; + } + this.root = this.buildNode(indices); + } + queryOverlappingRaw(minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): number[] { + const result: number[] = []; + this.queryNode(this.root, minX, minY, minZ, maxX, maxY, maxZ, result); + return result; + } + queryOverlappingRawInto( + minX: number, + minY: number, + minZ: number, + maxX: number, + maxY: number, + maxZ: number, + output: Uint32Array, + offset = 0, + ): number { + return this.queryNodeInto(this.root, minX, minY, minZ, maxX, maxY, maxZ, output, offset, 0); + } + private computeBounds(indices: Uint32Array): BVHBounds { + let minX = Infinity, + minY = Infinity, + minZ = Infinity; + let maxX = -Infinity, + maxY = -Infinity, + maxZ = -Infinity; + for (let i = 0; i < indices.length; i++) { + const idx = indices[i]; + const ex = this.extents[idx * 3], + ey = this.extents[idx * 3 + 1], + ez = this.extents[idx * 3 + 2]; + const gx = this.x[idx], + gy = this.y[idx], + gz = this.z[idx]; + if (gx - ex < minX) { + minX = gx - ex; + } + if (gy - ey < minY) { + minY = gy - ey; + } + if (gz - ez < minZ) { + minZ = gz - ez; + } + if (gx + ex > maxX) { + maxX = gx + ex; + } + if (gy + ey > maxY) { + maxY = gy + ey; + } + if (gz + ez > maxZ) { + maxZ = gz + ez; + } + } + return { minX, minY, minZ, maxX, maxY, maxZ }; + } + private buildNode(indices: Uint32Array): BVHNode { + const bounds = this.computeBounds(indices); + if (indices.length <= GaussianBVH.MAX_LEAF_SIZE) { + return { bounds, indices }; + } + let minCx = Infinity, + minCy = Infinity, + minCz = Infinity; + let maxCx = -Infinity, + maxCy = -Infinity, + maxCz = -Infinity; + for (let i = 0; i < indices.length; i++) { + const idx = indices[i]; + const cx = this.x[idx], + cy = this.y[idx], + cz = this.z[idx]; + if (cx < minCx) { + minCx = cx; + } + if (cy < minCy) { + minCy = cy; + } + if (cz < minCz) { + minCz = cz; + } + if (cx > maxCx) { + maxCx = cx; + } + if (cy > maxCy) { + maxCy = cy; + } + if (cz > maxCz) { + maxCz = cz; + } + } + const ex = maxCx - minCx, + ey = maxCy - minCy, + ez = maxCz - minCz; + const axis = ex >= ey && ex >= ez ? this.x : ey >= ez ? this.y : this.z; + const mid = indices.length >>> 1; + quickselect(axis, indices, mid); + return { bounds, left: this.buildNode(indices.subarray(0, mid)), right: this.buildNode(indices.subarray(mid)) }; + } + private queryNode( + node: BVHNode, + minX: number, + minY: number, + minZ: number, + maxX: number, + maxY: number, + maxZ: number, + result: number[], + ) { + if (!boundsOverlap(node.bounds, minX, minY, minZ, maxX, maxY, maxZ)) { + return; + } + if (node.indices) { + for (let i = 0; i < node.indices.length; i++) { + const idx = node.indices[i]; + const ex = this.extents[idx * 3], + ey = this.extents[idx * 3 + 1], + ez = this.extents[idx * 3 + 2]; + const gx = this.x[idx], + gy = this.y[idx], + gz = this.z[idx]; + if ( + !( + gx + ex < minX || + gx - ex > maxX || + gy + ey < minY || + gy - ey > maxY || + gz + ez < minZ || + gz - ez > maxZ + ) + ) { + result.push(idx); + } + } + return; + } + if (node.left) { + this.queryNode(node.left, minX, minY, minZ, maxX, maxY, maxZ, result); + } + if (node.right) { + this.queryNode(node.right, minX, minY, minZ, maxX, maxY, maxZ, result); + } + } + private queryNodeInto( + node: BVHNode, + minX: number, + minY: number, + minZ: number, + maxX: number, + maxY: number, + maxZ: number, + output: Uint32Array, + offset: number, + count: number, + ): number { + if (!boundsOverlap(node.bounds, minX, minY, minZ, maxX, maxY, maxZ)) { + return count; + } + if (node.indices) { + for (let i = 0; i < node.indices.length; i++) { + const idx = node.indices[i]; + const ex = this.extents[idx * 3], + ey = this.extents[idx * 3 + 1], + ez = this.extents[idx * 3 + 2]; + const gx = this.x[idx], + gy = this.y[idx], + gz = this.z[idx]; + if ( + !( + gx + ex < minX || + gx - ex > maxX || + gy + ey < minY || + gy - ey > maxY || + gz + ez < minZ || + gz - ez > maxZ + ) + ) { + if (offset + count < output.length) { + output[offset + count] = idx; + } + count++; + } + } + return count; + } + if (node.left) { + count = this.queryNodeInto(node.left, minX, minY, minZ, maxX, maxY, maxZ, output, offset, count); + } + if (node.right) { + count = this.queryNodeInto(node.right, minX, minY, minZ, maxX, maxY, maxZ, output, offset, count); + } + return count; + } +} + +const SOLID_LO = 0xffffffff >>> 0; +const SOLID_HI = 0xffffffff >>> 0; +const SOLID_MASK = 0xffffffff >>> 0; +const INITIAL_BLOCK_BUFFER_CAPACITY = 1024; + +function growFloat64(src: Float64Array, newCap: number): Float64Array { + const grown = new Float64Array(newCap); + grown.set(src); + return grown; +} + +function growUint32(src: Uint32Array, newCap: number): Uint32Array { + const grown = new Uint32Array(newCap); + grown.set(src); + return grown; +} + +/** + * Append-only buffer for streaming voxelization results. + * Stores (linear blockIdx, voxel mask) pairs for non-empty 4x4x4 blocks. + * + * Block keys are linear block indices `bx + by*nbx + bz*nbx*nby` in the + * producer's grid coordinate system. Producers and consumers must agree on + * the grid dimensions; the buffer itself is dimension-agnostic. + */ +export class BlockMaskBuffer { + private solidIdx: Float64Array = new Float64Array(0); + private solidCountValue = 0; + private solidCap = 0; + + private mixedIdx: Float64Array = new Float64Array(0); + private mixedCountValue = 0; + private mixedCap = 0; + private mixedMasks: Uint32Array = new Uint32Array(0); + + addBlock(blockIdx: number, lo: number, hi: number): void { + if ((lo | hi) === 0) { + return; + } + if (lo >>> 0 === SOLID_MASK && hi >>> 0 === SOLID_MASK) { + if (this.solidCountValue === this.solidCap) { + this.solidCap = this.solidCap === 0 ? INITIAL_BLOCK_BUFFER_CAPACITY : this.solidCap * 2; + this.solidIdx = growFloat64(this.solidIdx, this.solidCap); + } + this.solidIdx[this.solidCountValue++] = blockIdx; + return; + } + if (this.mixedCountValue === this.mixedCap) { + this.mixedCap = this.mixedCap === 0 ? INITIAL_BLOCK_BUFFER_CAPACITY : this.mixedCap * 2; + this.mixedIdx = growFloat64(this.mixedIdx, this.mixedCap); + this.mixedMasks = growUint32(this.mixedMasks, this.mixedCap * 2); + } + this.mixedIdx[this.mixedCountValue] = blockIdx; + this.mixedMasks[this.mixedCountValue * 2] = lo >>> 0; + this.mixedMasks[this.mixedCountValue * 2 + 1] = hi >>> 0; + this.mixedCountValue++; + } + + getMixedBlocks(): { blockIdx: Float64Array; masks: Uint32Array } { + return { + blockIdx: this.mixedIdx.subarray(0, this.mixedCountValue), + masks: this.mixedMasks.subarray(0, this.mixedCountValue * 2), + }; + } + + getSolidBlocks(): Float64Array { + return this.solidIdx.subarray(0, this.solidCountValue); + } + + get count(): number { + return this.mixedCountValue + this.solidCountValue; + } + + get mixedCount(): number { + return this.mixedCountValue; + } + + get solidCount(): number { + return this.solidCountValue; + } + + clear(): void { + this.solidIdx = new Float64Array(0); + this.solidCountValue = 0; + this.solidCap = 0; + + this.mixedIdx = new Float64Array(0); + this.mixedMasks = new Uint32Array(0); + this.mixedCountValue = 0; + this.mixedCap = 0; + } +} + +const BLOCK_EMPTY = 0; +const BLOCK_SOLID = 1; +const BLOCK_MIXED = 2; +const TYPE_MASK = 0x3; +const BLOCKS_PER_WORD = 16; +const EVEN_BITS = 0x55555555 >>> 0; + +function getBlockTypeWordIndex(blockIdx: number): number { + return Math.floor(blockIdx / BLOCKS_PER_WORD); +} + +function getBlockTypeBitShift(blockIdx: number): number { + return (blockIdx % BLOCKS_PER_WORD) << 1; +} + +function getBlockTypeWordCount(totalBlocks: number): number { + return Math.ceil(totalBlocks / BLOCKS_PER_WORD); +} + +function readBlockType(types: Uint32Array, blockIdx: number): number { + const word = getBlockTypeWordIndex(blockIdx); + const shift = getBlockTypeBitShift(blockIdx); + return (types[word] >>> shift) & TYPE_MASK; +} + +function writeBlockType(types: Uint32Array, blockIdx: number, blockType: number): void { + const word = getBlockTypeWordIndex(blockIdx); + const shift = getBlockTypeBitShift(blockIdx); + const mask = TYPE_MASK << shift; + types[word] = (types[word] & ~mask) | ((blockType & TYPE_MASK) << shift); +} + +const EMPTY = -1; + +class BlockMaskMap { + keys: Int32Array; + lo: Uint32Array; + hi: Uint32Array; + private _size: number; + private _capacity: number; + private _mask: number; + constructor(initialCapacity = 4096) { + const cap = 1 << (32 - Math.clz32(Math.max(15, initialCapacity - 1))); + this._capacity = cap; + this._mask = cap - 1; + this._size = 0; + this.keys = new Int32Array(cap).fill(EMPTY); + this.lo = new Uint32Array(cap); + this.hi = new Uint32Array(cap); + } + slot(key: number): number { + const mask = this._mask; + let i = (Math.imul(key, 0x9e3779b9) >>> 0) & mask; + while (true) { + const k = this.keys[i]; + if (k === key || k === EMPTY) { + return i; + } + i = (i + 1) & mask; + } + } + set(key: number, loVal: number, hiVal: number): void { + let s = this.slot(key); + if (this.keys[s] === EMPTY) { + this.keys[s] = key; + this._size++; + if (this._size > ((this._capacity * 0.7) | 0)) { + this._grow(); + s = this.slot(key); + } + } + this.lo[s] = loVal; + this.hi[s] = hiVal; + } + removeAt(slot: number): void { + this._size--; + const mask = this._mask; + let i = slot; + let j = slot; + while (true) { + j = (j + 1) & mask; + if (this.keys[j] === EMPTY) { + break; + } + const k = (Math.imul(this.keys[j], 0x9e3779b9) >>> 0) & mask; + if (i < j ? k <= i || k > j : k <= i && k > j) { + this.keys[i] = this.keys[j]; + this.lo[i] = this.lo[j]; + this.hi[i] = this.hi[j]; + i = j; + } + } + this.keys[i] = EMPTY; + } + clear(): void { + this.keys.fill(EMPTY); + this._size = 0; + } + get size(): number { + return this._size; + } + releaseStorage(): void { + this.keys = new Int32Array(0); + this.lo = new Uint32Array(0); + this.hi = new Uint32Array(0); + this._size = 0; + this._capacity = 0; + this._mask = 0; + } + clone(): BlockMaskMap { + const c = new BlockMaskMap(this._capacity); + c.keys.set(this.keys); + c.lo.set(this.lo); + c.hi.set(this.hi); + c._size = this._size; + return c; + } + private _grow(): void { + const oldKeys = this.keys; + const oldLo = this.lo; + const oldHi = this.hi; + const oldCap = this._capacity; + this._capacity *= 2; + this._mask = this._capacity - 1; + this.keys = new Int32Array(this._capacity).fill(EMPTY); + this.lo = new Uint32Array(this._capacity); + this.hi = new Uint32Array(this._capacity); + this._size = 0; + for (let i = 0; i < oldCap; i++) { + if (oldKeys[i] !== EMPTY) { + const s = this.slot(oldKeys[i]); + this.keys[s] = oldKeys[i]; + this.lo[s] = oldLo[i]; + this.hi[s] = oldHi[i]; + this._size++; + } + } + } +} + +class SparseVoxelGrid { + readonly nx: number; + readonly ny: number; + readonly nz: number; + readonly nbx: number; + readonly nby: number; + readonly nbz: number; + readonly bStride: number; + types: Uint32Array; + masks: BlockMaskMap; + constructor(nx: number, ny: number, nz: number) { + this.nx = nx; + this.ny = ny; + this.nz = nz; + this.nbx = nx >> 2; + this.nby = ny >> 2; + this.nbz = nz >> 2; + this.bStride = this.nbx * this.nby; + const totalBlocks = this.nbx * this.nby * this.nbz; + this.types = new Uint32Array(getBlockTypeWordCount(totalBlocks)); + this.masks = new BlockMaskMap(); + } + getVoxel(ix: number, iy: number, iz: number): number { + const blockIdx = (ix >> 2) + (iy >> 2) * this.nbx + (iz >> 2) * this.bStride; + const bt = readBlockType(this.types, blockIdx); + if (bt === BLOCK_EMPTY) { + return 0; + } + if (bt === BLOCK_SOLID) { + return 1; + } + const s = this.masks.slot(blockIdx); + const bitIdx = (ix & 3) + ((iy & 3) << 2) + ((iz & 3) << 4); + return bitIdx < 32 ? (this.masks.lo[s] >>> bitIdx) & 1 : (this.masks.hi[s] >>> (bitIdx - 32)) & 1; + } + setVoxel(ix: number, iy: number, iz: number): void { + const blockIdx = (ix >> 2) + (iy >> 2) * this.nbx + (iz >> 2) * this.bStride; + const bt = readBlockType(this.types, blockIdx); + if (bt === BLOCK_SOLID) { + return; + } + const bitIdx = (ix & 3) + ((iy & 3) << 2) + ((iz & 3) << 4); + if (bt === BLOCK_MIXED) { + const s = this.masks.slot(blockIdx); + if (bitIdx < 32) { + this.masks.lo[s] = (this.masks.lo[s] | (1 << bitIdx)) >>> 0; + } else { + this.masks.hi[s] = (this.masks.hi[s] | (1 << (bitIdx - 32))) >>> 0; + } + if (this.masks.lo[s] === SOLID_LO && this.masks.hi[s] === SOLID_HI) { + this.masks.removeAt(s); + writeBlockType(this.types, blockIdx, BLOCK_SOLID); + } + } else { + writeBlockType(this.types, blockIdx, BLOCK_MIXED); + this.masks.set( + blockIdx, + bitIdx < 32 ? (1 << bitIdx) >>> 0 : 0, + bitIdx >= 32 ? (1 << (bitIdx - 32)) >>> 0 : 0, + ); + } + } + orBlock(blockIdx: number, lo: number, hi: number): void { + if (lo === 0 && hi === 0) { + return; + } + const bt = readBlockType(this.types, blockIdx); + if (bt === BLOCK_SOLID) { + return; + } + if (bt === BLOCK_MIXED) { + const s = this.masks.slot(blockIdx); + this.masks.lo[s] = (this.masks.lo[s] | lo) >>> 0; + this.masks.hi[s] = (this.masks.hi[s] | hi) >>> 0; + if (this.masks.lo[s] === SOLID_LO && this.masks.hi[s] === SOLID_HI) { + this.masks.removeAt(s); + writeBlockType(this.types, blockIdx, BLOCK_SOLID); + } + } else { + if (lo >>> 0 === SOLID_LO && hi >>> 0 === SOLID_HI) { + writeBlockType(this.types, blockIdx, BLOCK_SOLID); + } else { + writeBlockType(this.types, blockIdx, BLOCK_MIXED); + this.masks.set(blockIdx, lo >>> 0, hi >>> 0); + } + } + } + clear(): void { + this.types.fill(0); + this.masks.clear(); + } + releaseStorage(): void { + this.types = new Uint32Array(0); + this.masks.releaseStorage(); + } + clone(): SparseVoxelGrid { + const g = new SparseVoxelGrid(this.nx, this.ny, this.nz); + g.types.set(this.types); + g.masks = this.masks.clone(); + return g; + } + cropTo( + cropMinBx: number, + cropMinBy: number, + cropMinBz: number, + cropMaxBx: number, + cropMaxBy: number, + cropMaxBz: number, + onProgress?: (done: number, total: number) => void, + ): SparseVoxelGrid { + const outNbx = cropMaxBx - cropMinBx; + const outNby = cropMaxBy - cropMinBy; + const outNbz = cropMaxBz - cropMinBz; + const out = new SparseVoxelGrid(outNbx * 4, outNby * 4, outNbz * 4); + const outBStride = outNbx * outNby; + const { nbx, nby } = this; + const totalBlocks = nbx * nby * this.nbz; + const types = this.types; + const masks = this.masks; + const outTypes = out.types; + const outMasks = out.masks; + if (out.nbx * out.nby * out.nbz === 0) { + if (onProgress) { + onProgress(0, 0); + } + return out; + } + const PROGRESS_INTERVAL = 1 << 13; + let nextTick = PROGRESS_INTERVAL; + for (let w = 0; w < types.length; w++) { + if (onProgress && w >= nextTick) { + onProgress(w, types.length); + nextTick = w + PROGRESS_INTERVAL; + } + const word = types[w]; + if (word === 0) { + continue; + } + let nonEmpty = ((word & EVEN_BITS) | ((word >>> 1) & EVEN_BITS)) >>> 0; + const baseIdx = w * BLOCKS_PER_WORD; + let bx = baseIdx % nbx; + const byBz = (baseIdx / nbx) | 0; + let by = byBz % nby; + let bz = (byBz / nby) | 0; + let coordLane = 0; + while (nonEmpty) { + const bp = 31 - Math.clz32(nonEmpty & -nonEmpty); + const lane = bp >>> 1; + nonEmpty &= nonEmpty - 1; + const blockIdx = baseIdx + lane; + if (blockIdx >= totalBlocks) { + break; + } + bx += lane - coordLane; + coordLane = lane; + while (bx >= nbx) { + bx -= nbx; + by++; + if (by >= nby) { + by = 0; + bz++; + } + } + if ( + bx < cropMinBx || + bx >= cropMaxBx || + by < cropMinBy || + by >= cropMaxBy || + bz < cropMinBz || + bz >= cropMaxBz + ) { + continue; + } + const outIdx = bx - cropMinBx + (by - cropMinBy) * outNbx + (bz - cropMinBz) * outBStride; + const bt = (word >>> (lane << 1)) & TYPE_MASK; + writeBlockType(outTypes, outIdx, bt); + if (bt === BLOCK_MIXED) { + const s = masks.slot(blockIdx); + outMasks.set(outIdx, masks.lo[s], masks.hi[s]); + } + } + } + if (onProgress) { + onProgress(types.length, types.length); + } + return out; + } + cropToInverted( + cropMinBx: number, + cropMinBy: number, + cropMinBz: number, + cropMaxBx: number, + cropMaxBy: number, + cropMaxBz: number, + onProgress?: (done: number, total: number) => void, + ): SparseVoxelGrid { + const outNbx = cropMaxBx - cropMinBx; + const outNby = cropMaxBy - cropMinBy; + const outNbz = cropMaxBz - cropMinBz; + const out = new SparseVoxelGrid(outNbx * 4, outNby * 4, outNbz * 4); + const outBStride = outNbx * outNby; + const outTotalBlocks = outNbx * outNby * outNbz; + const outTypes = out.types; + const outMasks = out.masks; + if (outTotalBlocks === 0) { + if (onProgress) { + onProgress(0, 0); + } + return out; + } + const SOLID_WORD = 0x55555555 >>> 0; + outTypes.fill(SOLID_WORD); + const lastWord = outTypes.length - 1; + const lastLanes = outTotalBlocks - lastWord * BLOCKS_PER_WORD; + if (lastLanes < BLOCKS_PER_WORD) { + const validBits = (1 << (lastLanes * 2)) - 1; + outTypes[lastWord] = (outTypes[lastWord] & validBits) >>> 0; + } + const { nbx, nby } = this; + const types = this.types; + const masks = this.masks; + const totalBlocks = nbx * nby * this.nbz; + const PROGRESS_INTERVAL = 1 << 13; + let nextTick = PROGRESS_INTERVAL; + for (let w = 0; w < types.length; w++) { + if (onProgress && w >= nextTick) { + onProgress(w, types.length); + nextTick = w + PROGRESS_INTERVAL; + } + const word = types[w]; + if (word === 0) { + continue; + } + let nonEmpty = ((word & EVEN_BITS) | ((word >>> 1) & EVEN_BITS)) >>> 0; + const baseIdx = w * BLOCKS_PER_WORD; + let bx = baseIdx % nbx; + const byBz = (baseIdx / nbx) | 0; + let by = byBz % nby; + let bz = (byBz / nby) | 0; + let coordLane = 0; + while (nonEmpty) { + const bp = 31 - Math.clz32(nonEmpty & -nonEmpty); + const lane = bp >>> 1; + nonEmpty &= nonEmpty - 1; + const blockIdx = baseIdx + lane; + if (blockIdx >= totalBlocks) { + break; + } + bx += lane - coordLane; + coordLane = lane; + while (bx >= nbx) { + bx -= nbx; + by++; + if (by >= nby) { + by = 0; + bz++; + } + } + if ( + bx < cropMinBx || + bx >= cropMaxBx || + by < cropMinBy || + by >= cropMaxBy || + bz < cropMinBz || + bz >= cropMaxBz + ) { + continue; + } + const outIdx = bx - cropMinBx + (by - cropMinBy) * outNbx + (bz - cropMinBz) * outBStride; + const bt = (word >>> (lane << 1)) & TYPE_MASK; + if (bt === BLOCK_SOLID) { + writeBlockType(outTypes, outIdx, BLOCK_EMPTY); + } else { + writeBlockType(outTypes, outIdx, BLOCK_MIXED); + const s = masks.slot(blockIdx); + outMasks.set(outIdx, ~masks.lo[s] >>> 0, ~masks.hi[s] >>> 0); + } + } + } + if (onProgress) { + onProgress(types.length, types.length); + } + return out; + } + static fromBuffer(acc: BlockMaskBuffer, nx: number, ny: number, nz: number): SparseVoxelGrid { + const g = new SparseVoxelGrid(nx, ny, nz); + const solidBlocks = acc.getSolidBlocks(); + const totalBlocks = g.nbx * g.nby * g.nbz; + for (let i = 0; i < solidBlocks.length; i++) { + const blockIdx = solidBlocks[i]; + if (blockIdx < 0 || blockIdx >= totalBlocks) { + continue; + } + writeBlockType(g.types, blockIdx, BLOCK_SOLID); + } + const mixed = acc.getMixedBlocks(); + for (let i = 0; i < mixed.blockIdx.length; i++) { + const blockIdx = mixed.blockIdx[i]; + if (blockIdx < 0 || blockIdx >= totalBlocks) { + continue; + } + if (readBlockType(g.types, blockIdx) === BLOCK_SOLID) { + continue; + } + writeBlockType(g.types, blockIdx, BLOCK_MIXED); + g.masks.set(blockIdx, mixed.masks[i * 2], mixed.masks[i * 2 + 1]); + } + return g; + } + toBuffer( + cropMinBx: number, + cropMinBy: number, + cropMinBz: number, + cropMaxBx: number, + cropMaxBy: number, + cropMaxBz: number, + defaultSolid = false, + ): BlockMaskBuffer { + const out = new BlockMaskBuffer(); + for (let bz = cropMinBz; bz < cropMaxBz; bz++) { + for (let by = cropMinBy; by < cropMaxBy; by++) { + for (let bx = cropMinBx; bx < cropMaxBx; bx++) { + const blockIdx = bx + by * this.nbx + bz * this.bStride; + const bt = readBlockType(this.types, blockIdx); + let lo: number; + let hi: number; + if (bt === BLOCK_SOLID) { + lo = SOLID_LO; + hi = SOLID_HI; + } else if (bt === BLOCK_MIXED) { + const s = this.masks.slot(blockIdx); + lo = this.masks.lo[s]; + hi = this.masks.hi[s]; + } else if (defaultSolid) { + lo = SOLID_LO; + hi = SOLID_HI; + } else { + continue; + } + if ((lo | hi) !== 0) { + const outNbx = cropMaxBx - cropMinBx; + const outNby = cropMaxBy - cropMinBy; + out.addBlock( + bx - cropMinBx + (by - cropMinBy) * outNbx + (bz - cropMinBz) * outNbx * outNby, + lo, + hi, + ); + } + } + } + } + return out; + } + toBufferInverted( + cropMinBx: number, + cropMinBy: number, + cropMinBz: number, + cropMaxBx: number, + cropMaxBy: number, + cropMaxBz: number, + ): BlockMaskBuffer { + const out = new BlockMaskBuffer(); + for (let bz = cropMinBz; bz < cropMaxBz; bz++) { + for (let by = cropMinBy; by < cropMaxBy; by++) { + for (let bx = cropMinBx; bx < cropMaxBx; bx++) { + const blockIdx = bx + by * this.nbx + bz * this.bStride; + const bt = readBlockType(this.types, blockIdx); + let lo: number; + let hi: number; + if (bt === BLOCK_SOLID) { + continue; + } + if (bt === BLOCK_MIXED) { + const s = this.masks.slot(blockIdx); + lo = ~this.masks.lo[s] >>> 0; + hi = ~this.masks.hi[s] >>> 0; + } else { + lo = SOLID_LO; + hi = SOLID_HI; + } + if ((lo | hi) !== 0) { + const outNbx = cropMaxBx - cropMinBx; + const outNby = cropMaxBy - cropMinBy; + out.addBlock( + bx - cropMinBx + (by - cropMinBy) * outNbx + (bz - cropMinBz) * outNbx * outNby, + lo, + hi, + ); + } + } + } + } + return out; + } + getOccupiedBlockBounds( + onProgress?: (done: number, total: number) => void, + ): { minBx: number; minBy: number; minBz: number; maxBx: number; maxBy: number; maxBz: number } | null { + const { nbx, nby } = this; + const totalBlocks = nbx * nby * this.nbz; + let minBx = nbx, + minBy = nby, + minBz = this.nbz; + let maxBx = 0, + maxBy = 0, + maxBz = 0; + let found = false; + const PROGRESS_INTERVAL = 1 << 13; + let nextTick = PROGRESS_INTERVAL; + for (let w = 0; w < this.types.length; w++) { + if (onProgress && w >= nextTick) { + onProgress(w, this.types.length); + nextTick = w + PROGRESS_INTERVAL; + } + const word = this.types[w]; + if (word === 0) { + continue; + } + let nonEmpty = ((word & EVEN_BITS) | ((word >>> 1) & EVEN_BITS)) >>> 0; + const baseIdx = w * BLOCKS_PER_WORD; + let bx = baseIdx % nbx; + const byBz = (baseIdx / nbx) | 0; + let by = byBz % nby; + let bz = (byBz / nby) | 0; + let coordLane = 0; + while (nonEmpty) { + const bitPos = 31 - Math.clz32(nonEmpty & -nonEmpty); + const lane = bitPos >>> 1; + const blockIdx = baseIdx + lane; + if (blockIdx >= totalBlocks) { + nonEmpty = 0; + break; + } + bx += lane - coordLane; + coordLane = lane; + while (bx >= nbx) { + bx -= nbx; + by++; + if (by >= nby) { + by = 0; + bz++; + } + } + if (bx < minBx) { + minBx = bx; + } + if (bx > maxBx) { + maxBx = bx; + } + if (by < minBy) { + minBy = by; + } + if (by > maxBy) { + maxBy = by; + } + if (bz < minBz) { + minBz = bz; + } + if (bz > maxBz) { + maxBz = bz; + } + found = true; + nonEmpty &= nonEmpty - 1; + } + } + if (onProgress) { + onProgress(this.types.length, this.types.length); + } + return found ? { minBx, minBy, minBz, maxBx, maxBy, maxBz } : null; + } + getNavigableBlockBounds( + onProgress?: (done: number, total: number) => void, + ): { minBx: number; minBy: number; minBz: number; maxBx: number; maxBy: number; maxBz: number } | null { + const { nbx, nby } = this; + const totalBlocks = nbx * nby * this.nbz; + if (totalBlocks === 0) { + if (onProgress) { + onProgress(0, 0); + } + return null; + } + const SOLID_WORD = 0x55555555 >>> 0; + const lastWordIdx = this.types.length - 1; + const lastLanes = totalBlocks - lastWordIdx * BLOCKS_PER_WORD; + const lastNonEmptyMask = + lastLanes >= BLOCKS_PER_WORD ? EVEN_BITS : (((1 << (lastLanes * 2)) - 1) >>> 0) & EVEN_BITS; + let minBx = nbx, + minBy = nby, + minBz = this.nbz; + let maxBx = -1, + maxBy = 0, + maxBz = 0; + const PROGRESS_INTERVAL = 1 << 13; + let nextTick = PROGRESS_INTERVAL; + for (let w = 0; w < this.types.length; w++) { + if (onProgress && w >= nextTick) { + onProgress(w, this.types.length); + nextTick = w + PROGRESS_INTERVAL; + } + const baseIdx = w * BLOCKS_PER_WORD; + const word = this.types[w]; + const flipped = (word ^ SOLID_WORD) >>> 0; + let navMask = ((flipped & EVEN_BITS) | ((flipped >>> 1) & EVEN_BITS)) >>> 0; + if (w === lastWordIdx) { + navMask &= lastNonEmptyMask; + } + let bx = baseIdx % nbx; + const byBz = (baseIdx / nbx) | 0; + let by = byBz % nby; + let bz = (byBz / nby) | 0; + let coordLane = 0; + while (navMask) { + const bp = 31 - Math.clz32(navMask & -navMask); + const lane = bp >>> 1; + bx += lane - coordLane; + coordLane = lane; + while (bx >= nbx) { + bx -= nbx; + by++; + if (by >= nby) { + by = 0; + bz++; + } + } + if (bx < minBx) { + minBx = bx; + } + if (bx > maxBx) { + maxBx = bx; + } + if (by < minBy) { + minBy = by; + } + if (by > maxBy) { + maxBy = by; + } + if (bz < minBz) { + minBz = bz; + } + if (bz > maxBz) { + maxBz = bz; + } + navMask &= navMask - 1; + } + } + if (onProgress) { + onProgress(this.types.length, this.types.length); + } + return maxBx >= 0 ? { minBx, minBy, minBz, maxBx, maxBy, maxBz } : null; + } + static findNearestFreeCell( + blocked: SparseVoxelGrid, + seedIx: number, + seedIy: number, + seedIz: number, + maxRadius: number, + ): { ix: number; iy: number; iz: number } | null { + const { nx, ny, nz } = blocked; + for (let r = 1; r <= maxRadius; r++) { + for (let dz = -r; dz <= r; dz++) { + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.abs(dx) !== r && Math.abs(dy) !== r && Math.abs(dz) !== r) { + continue; + } + const ix = seedIx + dx; + const iy = seedIy + dy; + const iz = seedIz + dz; + if (ix < 0 || ix >= nx || iy < 0 || iy >= ny || iz < 0 || iz >= nz) { + continue; + } + if (!blocked.getVoxel(ix, iy, iz)) { + return { ix, iy, iz }; + } + } + } + } + } + return null; + } +} + +export const SOLID_LEAF_MARKER = 0xff000000 >>> 0; +export const MAX_24BIT_OFFSET = 0x00ffffff; +const DENSE_SOLID_STREAM_THRESHOLD = 8_000_000; + +export class SparseOctree24BitOverflowError extends Error { + kind: 'node' | 'mixed-leaf'; + actual: number; + limit: number; + voxelResolution?: number; + + constructor(kind: 'node' | 'mixed-leaf', actual: number, limit: number) { + super( + `Sparse octree ${kind} count (${actual}) exceeds the Laine-Karras 24-bit baseOffset limit (${limit}). Reduce the grid size or split the scene.`, + ); + this.name = 'SparseOctree24BitOverflowError'; + this.kind = kind; + this.actual = actual; + this.limit = limit; + } +} + +export interface SparseOctree { + gridBounds: Bounds; + sceneBounds: Bounds; + voxelResolution: number; + leafSize: number; + treeDepth: number; + numInteriorNodes: number; + numMixedLeaves: number; + nodes: Uint32Array; + leafData: Uint32Array; +} + +export function getChildOffset(mask: number, octant: number) { + return popcount(mask & ((1 << octant) - 1)); +} + +export function popcount(n: number) { + let v = n >>> 0; + v -= (v >>> 1) & 0x55555555; + v = (v & 0x33333333) + ((v >>> 2) & 0x33333333); + return (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24; +} + +function sortMixedByMorton(mortons: Float64Array, masks: Uint32Array, n = mortons.length): void { + if (n <= 1) { + return; + } + const stackLo: number[] = [0]; + const stackHi: number[] = [n - 1]; + function swap(a: number, b: number) { + const km = mortons[a]; + mortons[a] = mortons[b]; + mortons[b] = km; + const alo = masks[a * 2]; + const ahi = masks[a * 2 + 1]; + masks[a * 2] = masks[b * 2]; + masks[a * 2 + 1] = masks[b * 2 + 1]; + masks[b * 2] = alo; + masks[b * 2 + 1] = ahi; + } + while (stackLo.length > 0) { + const lo = stackLo.pop()!; + const hi = stackHi.pop()!; + if (hi - lo < 16) { + for (let i = lo + 1; i <= hi; i++) { + const km = mortons[i]; + const m0 = masks[i * 2]; + const m1 = masks[i * 2 + 1]; + let j = i - 1; + while (j >= lo && mortons[j] > km) { + mortons[j + 1] = mortons[j]; + masks[(j + 1) * 2] = masks[j * 2]; + masks[(j + 1) * 2 + 1] = masks[j * 2 + 1]; + j--; + } + mortons[j + 1] = km; + masks[(j + 1) * 2] = m0; + masks[(j + 1) * 2 + 1] = m1; + } + continue; + } + const mid = (lo + hi) >>> 1; + if (mortons[mid] < mortons[lo]) { + swap(mid, lo); + } + if (mortons[hi] < mortons[lo]) { + swap(hi, lo); + } + if (mortons[hi] < mortons[mid]) { + swap(hi, mid); + } + const pivot = mortons[mid]; + let i = lo; + let j = hi; + while (i <= j) { + while (mortons[i] < pivot) { + i++; + } + while (mortons[j] > pivot) { + j--; + } + if (i <= j) { + if (i !== j) { + swap(i, j); + } + i++; + j--; + } + } + if (j - lo > hi - i) { + if (lo < j) { + stackLo.push(lo); + stackHi.push(j); + } + if (i < hi) { + stackLo.push(i); + stackHi.push(hi); + } + } else { + if (i < hi) { + stackLo.push(i); + stackHi.push(hi); + } + if (lo < j) { + stackLo.push(lo); + stackHi.push(j); + } + } + } +} + +enum OctreeNodeType { + Empty = 0, + Solid = 1, + Mixed = 2, +} + +interface LevelData { + mortons: number[]; + types: number[]; + childMasks: number[]; +} + +interface BuildSparseOctreeOptions { + consumeGrid?: boolean; + dense?: boolean; +} + +interface DenseLevel { + types: Uint32Array; + nbx: number; + nby: number; + nbz: number; + nonEmptyCount: number; +} + +interface InteriorWave { + pos: Uint32Array; + li: Uint32Array; + ii: Uint32Array; + length: number; +} + +function createInteriorWave(initialCapacity: number): InteriorWave { + const cap = Math.max(16, initialCapacity); + return { pos: new Uint32Array(cap), li: new Uint32Array(cap), ii: new Uint32Array(cap), length: 0 }; +} + +function pushInteriorWave(wave: InteriorWave, pos: number, li: number, ii: number): void { + if (wave.length === wave.pos.length) { + const cap = wave.pos.length * 2; + const grownPos = new Uint32Array(cap); + const grownLi = new Uint32Array(cap); + const grownIi = new Uint32Array(cap); + grownPos.set(wave.pos); + grownLi.set(wave.li); + grownIi.set(wave.ii); + wave.pos = grownPos; + wave.li = grownLi; + wave.ii = grownIi; + } + const i = wave.length++; + wave.pos[i] = pos; + wave.li[i] = li; + wave.ii[i] = ii; +} + +function shouldUseDenseMipBuild(totalBlocks: number, nSolid: number, nMixed: number): boolean { + return nSolid >= DENSE_SOLID_STREAM_THRESHOLD && nSolid > nMixed * 4 && nSolid > totalBlocks * 0.25; +} + +function buildDenseTypeLevels(grid: SparseVoxelGrid, maxDepth: number): DenseLevel[] { + const levels: DenseLevel[] = [ + { + types: grid.types, + nbx: grid.nbx, + nby: grid.nby, + nbz: grid.nbz, + nonEmptyCount: 0, + }, + ]; + + for (let li = 1; li <= maxDepth; li++) { + const prev = levels[li - 1]; + const nbx = Math.max(1, Math.ceil(prev.nbx / 2)); + const nby = Math.max(1, Math.ceil(prev.nby / 2)); + const nbz = Math.max(1, Math.ceil(prev.nbz / 2)); + const total = nbx * nby * nbz; + const types = new Uint32Array(getBlockTypeWordCount(total)); + const prevStride = prev.nbx * prev.nby; + const stride = nbx * nby; + let nonEmptyCount = 0; + + for (let pz = 0; pz < nbz; pz++) { + const childZ0 = pz << 1; + for (let py = 0; py < nby; py++) { + const childY0 = py << 1; + for (let px = 0; px < nbx; px++) { + const childX0 = px << 1; + let childMask = 0; + let allSolid = true; + let childCount = 0; + for (let oct = 0; oct < 8; oct++) { + const cx = childX0 + (oct & 1); + const cy = childY0 + ((oct >> 1) & 1); + const cz = childZ0 + ((oct >> 2) & 1); + if (cx >= prev.nbx || cy >= prev.nby || cz >= prev.nbz) { + continue; + } + const childIdx = cx + cy * prev.nbx + cz * prevStride; + const bt = readBlockType(prev.types, childIdx); + if (bt === BLOCK_EMPTY) { + continue; + } + childMask |= 1 << oct; + childCount++; + if (bt !== BLOCK_SOLID) { + allSolid = false; + } + } + if (childMask !== 0) { + const parentIdx = px + py * nbx + pz * stride; + writeBlockType(types, parentIdx, allSolid && childCount === 8 ? BLOCK_SOLID : BLOCK_MIXED); + nonEmptyCount++; + } + } + } + } + levels.push({ types, nbx, nby, nbz, nonEmptyCount }); + if (nonEmptyCount === 0) { + break; + } + if (nonEmptyCount === 1 && readBlockType(types, 0) !== BLOCK_EMPTY) { + break; + } + } + + return levels; +} + +function lowerBoundF64(arr: Float64Array, target: number, n: number): number { + let lo = 0; + let hi = n; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (arr[mid] < target) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo; +} + +function flattenTreeFromLevels( + interiorLevels: LevelData[], + solidStream: Float64Array, + mixedStream: Float64Array, + mixedMasks: Uint32Array, + nSolid: number, + nMixed: number, + gridBounds: Bounds, + sceneBounds: Bounds, + voxelResolution: number, + treeDepth: number, +): SparseOctree { + if (interiorLevels.length === 0) { + return { + gridBounds, + sceneBounds, + voxelResolution, + leafSize: LEAF_SIZE, + treeDepth, + numInteriorNodes: 0, + numMixedLeaves: 0, + nodes: new Uint32Array(0), + leafData: new Uint32Array(0), + }; + } + const rootLevel = interiorLevels[interiorLevels.length - 1]; + let maxNodes = nSolid + nMixed; + for (let l = 0; l < interiorLevels.length; l++) { + maxNodes += interiorLevels[l].mortons.length; + } + const nodes = new Uint32Array(maxNodes); + const leafData = new Uint32Array(nMixed * 2); + let leafDataLen = 0; + let numInteriorNodes = 0; + let numMixedLeaves = 0; + let emitPos = 0; + let waveLi: number[] = []; + let waveIi: number[] = []; + const rootLi = interiorLevels.length - 1; + for (let i = 0; i < rootLevel.mortons.length; i++) { + waveLi.push(rootLi); + waveIi.push(i); + } + const intPos: number[] = []; + const intLi: number[] = []; + const intIi: number[] = []; + const intMask: number[] = []; + while (waveLi.length > 0) { + intPos.length = 0; + intLi.length = 0; + intIi.length = 0; + intMask.length = 0; + for (let w = 0; w < waveLi.length; w++) { + const li = waveLi[w]; + const ii = waveIi[w]; + if (li === -1) { + if (ii < nMixed) { + const leafDataIndex = leafDataLen >> 1; + if (leafDataIndex > MAX_24BIT_OFFSET) { + throw new SparseOctree24BitOverflowError('mixed-leaf', leafDataIndex + 1, MAX_24BIT_OFFSET + 1); + } + leafData[leafDataLen++] = mixedMasks[ii * 2]; + leafData[leafDataLen++] = mixedMasks[ii * 2 + 1]; + nodes[emitPos] = leafDataIndex; + numMixedLeaves++; + } else { + nodes[emitPos] = SOLID_LEAF_MARKER; + } + emitPos++; + continue; + } + const level = interiorLevels[li]; + const type = level.types[ii]; + if (type === OctreeNodeType.Solid) { + nodes[emitPos] = SOLID_LEAF_MARKER; + } else { + intPos.push(emitPos); + intLi.push(li); + intIi.push(ii); + intMask.push(level.childMasks[ii]); + numInteriorNodes++; + nodes[emitPos] = 0; + } + emitPos++; + } + const nextWaveLi: number[] = []; + const nextWaveIi: number[] = []; + let nextChildStart = emitPos; + for (let j = 0; j < intPos.length; j++) { + const childMask = intMask[j]; + const childCount = popcount(childMask); + if (nextChildStart > MAX_24BIT_OFFSET) { + throw new SparseOctree24BitOverflowError('node', nextChildStart + 1, MAX_24BIT_OFFSET + 1); + } + nodes[intPos[j]] = ((childMask & 0xff) << 24) | nextChildStart; + const myLi = intLi[j]; + const myMorton = interiorLevels[myLi].mortons[intIi[j]]; + const childMortonBase = myMorton * 8; + const childMortonEnd = childMortonBase + 8; + if (myLi === 0) { + let sIdx = lowerBoundF64(solidStream, childMortonBase, nSolid); + let mIdx = lowerBoundF64(mixedStream, childMortonBase, nMixed); + while (true) { + const sM = + sIdx < nSolid && solidStream[sIdx] < childMortonEnd + ? solidStream[sIdx] + : Number.POSITIVE_INFINITY; + const mM = + mIdx < nMixed && mixedStream[mIdx] < childMortonEnd + ? mixedStream[mIdx] + : Number.POSITIVE_INFINITY; + if (!isFinite(sM) && !isFinite(mM)) { + break; + } + if (sM < mM) { + nextWaveLi.push(-1); + nextWaveIi.push(nMixed + sIdx); + sIdx++; + } else { + nextWaveLi.push(-1); + nextWaveIi.push(mIdx); + mIdx++; + } + } + } else { + const childLi = myLi - 1; + const childLevel = interiorLevels[childLi]; + const childMortons = childLevel.mortons; + let lo = 0; + let hi = childMortons.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (childMortons[mid] < childMortonBase) { + lo = mid + 1; + } else { + hi = mid; + } + } + while (lo < childMortons.length && childMortons[lo] < childMortonEnd) { + nextWaveLi.push(childLi); + nextWaveIi.push(lo); + lo++; + } + } + nextChildStart += childCount; + } + waveLi = nextWaveLi; + waveIi = nextWaveIi; + } + return { + gridBounds, + sceneBounds, + voxelResolution, + leafSize: LEAF_SIZE, + treeDepth, + numInteriorNodes, + numMixedLeaves, + nodes: emitPos === maxNodes ? nodes : nodes.slice(0, emitPos), + leafData: leafDataLen === leafData.length ? leafData : leafData.slice(0, leafDataLen), + }; +} + +function flattenDenseLevels( + levels: Array, + grid: SparseVoxelGrid, + gridBounds: Bounds, + sceneBounds: Bounds, + voxelResolution: number, +): SparseOctree { + const treeDepth = Math.max(1, levels.length - 1); + const rootLi = levels.length - 1; + const rootLevel = levels[rootLi]!; + const rootType = readBlockType(rootLevel.types, 0); + if (rootType === BLOCK_EMPTY) { + return { + gridBounds, + sceneBounds, + voxelResolution, + leafSize: LEAF_SIZE, + treeDepth, + numInteriorNodes: 0, + numMixedLeaves: 0, + nodes: new Uint32Array(0), + leafData: new Uint32Array(0), + }; + } + + let nodes = new Uint32Array(Math.max(1024, Math.min(MAX_24BIT_OFFSET + 1, grid.masks.size * 3))); + let nodeLen = 0; + let leafData = new Uint32Array(Math.max(1024, grid.masks.size * 2)); + let leafDataLen = 0; + let numInteriorNodes = 0; + let numMixedLeaves = 0; + + function appendNode(value: number): number { + if (nodeLen === nodes.length) { + const grown = new Uint32Array(nodes.length * 2); + grown.set(nodes); + nodes = grown; + } + nodes[nodeLen] = value >>> 0; + return nodeLen++; + } + + function appendMixedLeaf(blockIdx: number): void { + const leafDataIndex = leafDataLen >> 1; + if (leafDataIndex > MAX_24BIT_OFFSET) { + throw new SparseOctree24BitOverflowError('mixed-leaf', leafDataIndex + 1, MAX_24BIT_OFFSET + 1); + } + if (leafDataLen + 2 > leafData.length) { + const grown = new Uint32Array(leafData.length * 2); + grown.set(leafData); + leafData = grown; + } + const s = grid.masks.slot(blockIdx); + leafData[leafDataLen++] = grid.masks.lo[s]; + leafData[leafDataLen++] = grid.masks.hi[s]; + appendNode(leafDataIndex); + numMixedLeaves++; + } + + let curWave = createInteriorWave(1); + let nextWave = createInteriorWave(1024); + function appendDenseNode(li: number, idx: number, wave: InteriorWave): void { + const level = levels[li]!; + const bt = readBlockType(level.types, idx); + if (bt === BLOCK_SOLID) { + appendNode(SOLID_LEAF_MARKER); + } else if (bt === BLOCK_MIXED) { + const pos = appendNode(0); + pushInteriorWave(wave, pos, li, idx); + numInteriorNodes++; + } + } + + appendDenseNode(rootLi, 0, curWave); + while (curWave.length > 0) { + nextWave.length = 0; + const currentLi = curWave.li[0]; + for (let w = 0; w < curWave.length; w++) { + const li = curWave.li[w]; + const parentLevel = levels[li]!; + const childLevel = levels[li - 1]!; + const parentIdx = curWave.ii[w]; + const px = parentIdx % parentLevel.nbx; + const pyBz = (parentIdx / parentLevel.nbx) | 0; + const py = pyBz % parentLevel.nby; + const pz = (pyBz / parentLevel.nby) | 0; + const childX0 = px << 1; + const childY0 = py << 1; + const childZ0 = pz << 1; + const childStride = childLevel.nbx * childLevel.nby; + const childStart = nodeLen; + let childMask = 0; + if (childStart > MAX_24BIT_OFFSET) { + throw new SparseOctree24BitOverflowError('node', childStart + 1, MAX_24BIT_OFFSET + 1); + } + for (let oct = 0; oct < 8; oct++) { + const cx = childX0 + (oct & 1); + const cy = childY0 + ((oct >> 1) & 1); + const cz = childZ0 + ((oct >> 2) & 1); + if (cx >= childLevel.nbx || cy >= childLevel.nby || cz >= childLevel.nbz) { + continue; + } + const childIdx = cx + cy * childLevel.nbx + cz * childStride; + const bt = readBlockType(childLevel.types, childIdx); + if (bt === BLOCK_EMPTY) { + continue; + } + childMask |= 1 << oct; + if (li === 1) { + if (bt === BLOCK_SOLID) { + appendNode(SOLID_LEAF_MARKER); + } else { + appendMixedLeaf(childIdx); + } + } else { + appendDenseNode(li - 1, childIdx, nextWave); + } + } + nodes[curWave.pos[w]] = ((childMask & 0xff) << 24) | childStart; + } + levels[currentLi] = null; + const tmp = curWave; + curWave = nextWave; + nextWave = tmp; + } + return { + gridBounds, + sceneBounds, + voxelResolution, + leafSize: LEAF_SIZE, + treeDepth, + numInteriorNodes, + numMixedLeaves, + nodes: nodes.slice(0, nodeLen), + leafData: leafData.slice(0, leafDataLen), + }; +} + +function buildSparseOctreeDense( + grid: SparseVoxelGrid, + gridBounds: Bounds, + sceneBounds: Bounds, + voxelResolution: number, + maxDepth: number, + consumeGrid: boolean, +): SparseOctree { + const levels = buildDenseTypeLevels(grid, maxDepth); + const result = flattenDenseLevels(levels, grid, gridBounds, sceneBounds, voxelResolution); + if (consumeGrid) { + grid.releaseStorage(); + } + return result; +} + +/** + * Build a sparse octree from block masks using: + * 1) mixed+solid SoA merge and Morton sort + * 2) bottom-up level construction by parent Morton grouping + * 3) BFS flatten to node/leafData arrays. + */ +export function buildSparseOctree( + grid: SparseVoxelGrid, + gridBounds: Bounds, + sceneBounds: Bounds, + voxelResolution: number, + options: BuildSparseOctreeOptions = {}, +): SparseOctree { + const { nbx, nby, nbz, types: gridTypes, masks: gridMasks } = grid; + const totalBlocks = nbx * nby * nbz; + const blocksPerAxis = Math.max(nbx, nby, nbz); + const treeDepth = Math.max(1, Math.ceil(Math.log2(blocksPerAxis))); + const lastWordIdx = gridTypes.length - 1; + const lastLanes = totalBlocks - lastWordIdx * BLOCKS_PER_WORD; + const lastValidWordMask = lastLanes >= BLOCKS_PER_WORD ? 0xffffffff >>> 0 : ((1 << (lastLanes * 2)) - 1) >>> 0; + let nSolid = 0; + let nMixed = 0; + for (let w = 0; w < gridTypes.length; w++) { + let word = gridTypes[w]; + if (w === lastWordIdx) { + word = (word & lastValidWordMask) >>> 0; + } + if (word === 0) { + continue; + } + const solidMask = word & EVEN_BITS & ~((word >>> 1) & EVEN_BITS); + const mixedMask = (word >>> 1) & EVEN_BITS & ~(word & EVEN_BITS); + nSolid += popcount(solidMask >>> 0); + nMixed += popcount(mixedMask >>> 0); + } + if (nSolid + nMixed === 0) { + return { + gridBounds, + sceneBounds, + voxelResolution, + leafSize: LEAF_SIZE, + treeDepth: 1, + numInteriorNodes: 0, + numMixedLeaves: 0, + nodes: new Uint32Array(0), + leafData: new Uint32Array(0), + }; + } + if (options.dense || shouldUseDenseMipBuild(totalBlocks, nSolid, nMixed)) { + return buildSparseOctreeDense(grid, gridBounds, sceneBounds, voxelResolution, treeDepth, !!options.consumeGrid); + } + const solidStream = new Float64Array(nSolid); + const mixedStream = new Float64Array(nMixed); + const mixedMasks = new Uint32Array(nMixed * 2); + let solidWriteIdx = 0; + let mixedWriteIdx = 0; + for (let w = 0; w < gridTypes.length; w++) { + let word = gridTypes[w]; + if (w === lastWordIdx) { + word = (word & lastValidWordMask) >>> 0; + } + if (word === 0) { + continue; + } + let nonEmpty = ((word & EVEN_BITS) | ((word >>> 1) & EVEN_BITS)) >>> 0; + const baseIdx = w * BLOCKS_PER_WORD; + while (nonEmpty) { + const bp = 31 - Math.clz32(nonEmpty & -nonEmpty); + const lane = bp >>> 1; + nonEmpty &= nonEmpty - 1; + const blockIdx = baseIdx + lane; + if (blockIdx >= totalBlocks) { + break; + } + const bx = blockIdx % nbx; + const byBz = (blockIdx / nbx) | 0; + const by = byBz % nby; + const bz = (byBz / nby) | 0; + const morton = encodeMorton3(bx, by, bz); + const bt = (word >>> (lane << 1)) & TYPE_MASK; + if (bt === OctreeNodeType.Solid) { + solidStream[solidWriteIdx++] = morton; + } else if (bt === OctreeNodeType.Mixed) { + mixedStream[mixedWriteIdx] = morton; + const s = gridMasks.slot(blockIdx); + mixedMasks[mixedWriteIdx * 2] = gridMasks.lo[s]; + mixedMasks[mixedWriteIdx * 2 + 1] = gridMasks.hi[s]; + mixedWriteIdx++; + } + } + } + if (options.consumeGrid) { + grid.releaseStorage(); + } + if (nSolid > 1) { + solidStream.sort(); + } + if (nMixed > 1) { + sortMixedByMorton(mixedStream, mixedMasks, nMixed); + } + const interiorLevels: LevelData[] = []; + let curMortons: number[] = []; + let curTypes: number[] = []; + let curChildMasks: number[] = []; + { + let sI = 0; + let mI = 0; + while (sI < nSolid || mI < nMixed) { + const sM0 = sI < nSolid ? solidStream[sI] : Number.POSITIVE_INFINITY; + const mM0 = mI < nMixed ? mixedStream[mI] : Number.POSITIVE_INFINITY; + const minMorton = sM0 < mM0 ? sM0 : mM0; + const parentMorton = Math.floor(minMorton / 8); + let childMask = 0; + let allSolid = true; + let childCount = 0; + while (true) { + const sM = sI < nSolid ? solidStream[sI] : Number.POSITIVE_INFINITY; + const mM = mI < nMixed ? mixedStream[mI] : Number.POSITIVE_INFINITY; + const cur = sM < mM ? sM : mM; + if (!isFinite(cur) || Math.floor(cur / 8) !== parentMorton) { + break; + } + childMask |= 1 << (cur % 8); + childCount++; + if (sM < mM) { + sI++; + } else { + allSolid = false; + mI++; + } + } + curMortons.push(parentMorton); + if (allSolid && childCount === 8) { + curTypes.push(OctreeNodeType.Solid); + curChildMasks.push(0); + } else { + curTypes.push(OctreeNodeType.Mixed); + curChildMasks.push(childMask); + } + } + } + let actualDepth = treeDepth; + if (curMortons.length === 0) { + actualDepth = 1; + } else if (curMortons.length === 1 && curMortons[0] === 0) { + actualDepth = 1; + interiorLevels.push({ mortons: curMortons, types: curTypes, childMasks: curChildMasks }); + } else { + for (let level = 1; level < treeDepth; level++) { + interiorLevels.push({ mortons: curMortons, types: curTypes, childMasks: curChildMasks }); + const n = curMortons.length; + const nextMortons: number[] = []; + const nextTypes: number[] = []; + const nextChildMasks: number[] = []; + let i = 0; + while (i < n) { + const parentMorton = Math.floor(curMortons[i] / 8); + let childMask = 0; + let allSolid = true; + let childCount = 0; + while (i < n && Math.floor(curMortons[i] / 8) === parentMorton) { + const octant = curMortons[i] % 8; + childMask |= 1 << octant; + if (curTypes[i] !== OctreeNodeType.Solid) { + allSolid = false; + } + childCount++; + i++; + } + nextMortons.push(parentMorton); + if (allSolid && childCount === 8) { + nextTypes.push(OctreeNodeType.Solid); + nextChildMasks.push(0); + } else { + nextTypes.push(OctreeNodeType.Mixed); + nextChildMasks.push(childMask); + } + } + curMortons = nextMortons; + curTypes = nextTypes; + curChildMasks = nextChildMasks; + if (curMortons.length === 1 && curMortons[0] === 0) { + actualDepth = level + 1; + break; + } + } + interiorLevels.push({ mortons: curMortons, types: curTypes, childMasks: curChildMasks }); + } + return flattenTreeFromLevels( + interiorLevels, + solidStream, + mixedStream, + mixedMasks, + nSolid, + nMixed, + gridBounds, + sceneBounds, + voxelResolution, + actualDepth, + ); +} + +export { + BLOCK_EMPTY, + BLOCK_SOLID, + BLOCK_MIXED, + BLOCKS_PER_WORD, + TYPE_MASK, + EVEN_BITS, + readBlockType, + writeBlockType, + SOLID_LO, + SOLID_HI, + SparseVoxelGrid, +}; diff --git a/packages/splat-transform/src/utils/voxel/coplanarMerge.ts b/packages/splat-transform/src/utils/voxel/coplanarMerge.ts new file mode 100644 index 0000000..1ecb03f --- /dev/null +++ b/packages/splat-transform/src/utils/voxel/coplanarMerge.ts @@ -0,0 +1,866 @@ +import type { Mesh } from './marchingCubes.js'; + +const NORMAL_EPS = 1e-3; +const PLANE_REL_EPS = 1e-3; +const COLLINEAR_REL_EPS = 1e-3; + +/** + * Losslessly reduce coplanar regions of a marching-cubes mesh by + * topology-preserving vertex removal. + * + * For a closed manifold MC mesh, a vertex `v` is "lossless-removable" iff + * its incident-tri fan, walked in cyclic order, falls into one of: + * + * 1. K=1 coplanar fan. Every triangle in v's fan lies on the same plane + * (same unit normal and same plane offset, within tolerance). Removing + * v is the inverse of vertex split: re-triangulate the boundary + * polygon in the same plane. + * + * 2. K=2 collinear seam. The fan splits into exactly two contiguous + * coplanar arcs (different planes). The two crease vertices `a` and + * `b` (the boundary points where the plane changes around v) are + * collinear with v in 3D, with v between them. Removing v collapses + * the two crease edges (v-a, v-b) into a single straight edge (a-b) + * that lies in both planes; each arc's polygon re-triangulates + * without v. + * + * Vertices with K >= 3 (multi-way corners) are kept. + * + * Removing a removable v is exact-lossless: the surface footprint is + * identical, no vertex moves and none are created. The transformation + * is the inverse of vertex split, so it is topology-preserving by + * construction: + * + * - No T-junctions. Every old vertex on the polygon boundary remains a + * vertex of every triangle that previously touched it. Adjacent fused + * regions and verbatim regions stay coupled at every shared vertex. + * - Watertight. The closed manifold structure is preserved across + * removal. Both the K=1 and K=2 cases preserve the K=2 seam edge as + * a single shared edge between the two plane groups. + * - Bit-exact. Every output position is a verbatim copy of an input + * position; no vertex is fabricated. + * + * Algorithm: + * + * 1. Build per-vertex incident-tri lists and per-tri normalized normals + * and plane offsets. + * 2. Process vertices via a dirty-flag worklist. Initially queue every + * vertex; after a successful removal, re-queue the ring neighbours + * so chains of K=1 / K=2 vertices collapse in one run. + * 3. For each dequeued vertex `v`: + * a. Walk the fan to extract the cyclic ring vertices and the + * cyclic ordered tris (each tri (v, ring[i], ring[(i+1)%k]) is + * the i-th tri in fan order). + * b. Decide K. If all tris share a plane: K=1. Otherwise count + * transitions in cyclic order; K=2 if exactly two arcs. + * c. K>=3: skip. K=2: verify ring[i1], v, ring[i2] are collinear. + * d. For each arc, project its polygon to 2D using the arc's + * plane basis, ear-clip, and append the new tris. + * e. Mark v's old tris dead, register the new tris in each polygon + * vertex's incident list, and re-queue the ring. + * 4. Compact: drop dead tris and unused vertices, remap indices. + * + * @param mesh - Input triangle mesh from {@link marchingCubes}. + * @param voxelResolution - Size of one voxel in world units. Used to scale the plane-offset tolerance. (The K=2 collinearity check is purely angular and has no voxel-scaled term.) + * @returns A new mesh with the same surface geometry, no T-junctions, and far fewer triangles. + */ +function coplanarMerge(mesh: Mesh, voxelResolution: number): Mesh { + const { positions, indices } = mesh; + const inputTriCount = (indices.length / 3) | 0; + const vertCount = (positions.length / 3) | 0; + + if (inputTriCount === 0) { + return { positions: new Float32Array(0), indices: new Uint32Array(0) }; + } + + // Plane-offset tolerance for the coplanarity test. An absolute floor + // (voxelResolution * PLANE_REL_EPS) handles near-origin planes, while + // a relative term scaled by max(|da|, |db|) handles large-offset + // planes where Float32 position precision in d = n . p causes + // relative error proportional to |d|. Without the relative term, + // coplanar fans far from the origin would be seen as distinct planes. + function planeEps(da: number, db: number): number { + const absA = da < 0 ? -da : da; + const absB = db < 0 ? -db : db; + return PLANE_REL_EPS * (voxelResolution + (absA > absB ? absA : absB)); + } + + // Mutable triangle table. Flat typed arrays so we can append new tris + // (from ear-clipping) without per-tri allocations and without hitting + // V8's regular-Array backing-store size cap on large meshes. + // `triVertsArr` indexes are 3*t, everything else is t. + // + // Normals are stored as Float32 (unit vectors; ample precision for the + // dot-product coplanarity test against `1 - NORMAL_EPS`). The plane + // offset stays Float64 since it's an absolute world-space scalar that + // can be large for distant scenes. + let triCap = inputTriCount; + let triVertsArr: Uint32Array = new Uint32Array(triCap * 3); + let triNxArr: Float32Array = new Float32Array(triCap); + let triNyArr: Float32Array = new Float32Array(triCap); + let triNzArr: Float32Array = new Float32Array(triCap); + let triDArr: Float64Array = new Float64Array(triCap); + let triAliveArr: Uint8Array = new Uint8Array(triCap); + let triCount = inputTriCount; + + // Capacity-doubling appenders for new tris generated by ear-clipping. + function ensureTriCap() { + if (triCount < triCap) { + return; + } + const newCap = triCap * 2; + function growF32(src: Float32Array): Float32Array { + const out = new Float32Array(newCap); + out.set(src); + return out; + } + function growF64(src: Float64Array): Float64Array { + const out = new Float64Array(newCap); + out.set(src); + return out; + } + const newVerts = new Uint32Array(newCap * 3); + newVerts.set(triVertsArr); + triVertsArr = newVerts; + triNxArr = growF32(triNxArr); + triNyArr = growF32(triNyArr); + triNzArr = growF32(triNzArr); + triDArr = growF64(triDArr); + const aliveOut = new Uint8Array(newCap); + aliveOut.set(triAliveArr); + triAliveArr = aliveOut; + triCap = newCap; + } + + // Pass 0: compute per-tri normalized normal and plane offset. + for (let t = 0; t < inputTriCount; t++) { + const ia = indices[t * 3]; + const ib = indices[t * 3 + 1]; + const ic = indices[t * 3 + 2]; + triVertsArr[t * 3] = ia; + triVertsArr[t * 3 + 1] = ib; + triVertsArr[t * 3 + 2] = ic; + + const ax = positions[ia * 3]; + const ay = positions[ia * 3 + 1]; + const az = positions[ia * 3 + 2]; + const bx = positions[ib * 3]; + const by = positions[ib * 3 + 1]; + const bz = positions[ib * 3 + 2]; + const cx = positions[ic * 3]; + const cy = positions[ic * 3 + 1]; + const cz = positions[ic * 3 + 2]; + + const ex = bx - ax, + ey = by - ay, + ez = bz - az; + const fx = cx - ax, + fy = cy - ay, + fz = cz - az; + let nx = ey * fz - ez * fy; + let ny = ez * fx - ex * fz; + let nz = ex * fy - ey * fx; + const nLen = Math.sqrt(nx * nx + ny * ny + nz * nz); + if (nLen < 1e-12) { + // Degenerate input tri; drop it from the active set. + triAliveArr[t] = 0; + continue; + } + const inv = 1 / nLen; + nx *= inv; + ny *= inv; + nz *= inv; + triNxArr[t] = nx; + triNyArr[t] = ny; + triNzArr[t] = nz; + triDArr[t] = nx * ax + ny * ay + nz * az; + triAliveArr[t] = 1; + } + + // Per-vertex incident-tri lists, stored as a singly-linked free-listed + // node pool backed by typed arrays. Each pool node `n` holds: + // poolTri[n] - the incident tri index + // poolNext[n] - next node in the same vertex's list (or -1 = end) + // `vertHead[v]` is the head node for vertex v (or -1 if v has no + // incident tris). Removed nodes are pushed onto the `freeHead` chain + // for reuse, so the pool's max occupancy is the initial fan-mention + // count (3 * inputTriCount): the worklist's K=1/K=2 collapses are + // monotonically tri-reducing, so the free list serves all subsequent + // ear-clip allocations without ever growing the backing arrays. + // + // Footprint: 8 B/node * 3 * inputTriCount + 4 B/vert * vertCount. + // For 25.7M raw tris / 12.9M raw verts that's ~615 MB pool + + // ~52 MB heads, vs ~1.4 GB for the prior `number[][]` adjacency + // (V8 Array headers, FixedArray headers, hidden classes per inner + // array all inflate the boxed-SMI payload). + const poolCap = inputTriCount * 3; + const poolTri = new Int32Array(poolCap); + const poolNext = new Int32Array(poolCap); + let poolLen = 0; + let freeHead = -1; + const vertHead = new Int32Array(vertCount).fill(-1); + + function allocNode(): number { + if (freeHead !== -1) { + const n = freeHead; + freeHead = poolNext[n]; + return n; + } + return poolLen++; + } + + function freeNode(n: number) { + poolNext[n] = freeHead; + freeHead = n; + } + + function addTriToVert(v: number, tri: number) { + const n = allocNode(); + poolTri[n] = tri; + poolNext[n] = vertHead[v]; + vertHead[v] = n; + } + + // O(degree) removal of `tri` from `v`'s incident list. Returns true + // if found and removed. + function removeTriFromVert(v: number, tri: number): boolean { + let prev = -1; + let cur = vertHead[v]; + while (cur !== -1) { + if (poolTri[cur] === tri) { + const nxt = poolNext[cur]; + if (prev === -1) { + vertHead[v] = nxt; + } else { + poolNext[prev] = nxt; + } + freeNode(cur); + return true; + } + prev = cur; + cur = poolNext[cur]; + } + return false; + } + + for (let t = 0; t < inputTriCount; t++) { + if (triAliveArr[t] === 0) { + continue; + } + addTriToVert(triVertsArr[t * 3], t); + addTriToVert(triVertsArr[t * 3 + 1], t); + addTriToVert(triVertsArr[t * 3 + 2], t); + } + + // Build a right-handed tangent / bitangent basis (t, b) on the plane + // with normal n, picking the cardinal axis least aligned with n to + // avoid precision loss in the cross product. By construction + // t x b = n, so a polygon traced CCW around +n in 3D projects to a + // CCW polygon in (t, b) coordinates (positive 2D signed area). + function buildBasis(nx: number, ny: number, nz: number): [number, number, number, number, number, number] { + const ax = Math.abs(nx); + const ay = Math.abs(ny); + const az = Math.abs(nz); + let tx: number, ty: number, tz: number; + if (ax <= ay && ax <= az) { + // X axis least aligned: t = (1, 0, 0) x n + tx = 0; + ty = -nz; + tz = ny; + } else if (ay <= az) { + // Y axis least aligned: t = (0, 1, 0) x n + tx = nz; + ty = 0; + tz = -nx; + } else { + // Z axis least aligned: t = (0, 0, 1) x n + tx = -ny; + ty = nx; + tz = 0; + } + const tlen = Math.sqrt(tx * tx + ty * ty + tz * tz); + const inv = 1 / tlen; + tx *= inv; + ty *= inv; + tz *= inv; + // bitangent = n x t (unit length because n and t are unit and + // perpendicular). + const bx = ny * tz - nz * ty; + const by = nz * tx - nx * tz; + const bz = nx * ty - ny * tx; + return [tx, ty, tz, bx, by, bz]; + } + + // Reusable scratch buffers for extractFan. With typical MC fan sizes + // (k <= ~12), a linear scan over parallel typed arrays is cheaper than + // the Map-of-Map-of-Set allocations the previous version paid per + // vertex, and saves ~3 allocations per worklist iteration on meshes + // with tens of millions of vertices. + let fanScratchCap = 16; + let fanFromScratch = new Int32Array(fanScratchCap); + let fanToScratch = new Int32Array(fanScratchCap); + let fanTriScratch = new Int32Array(fanScratchCap); + let fanRingScratch = new Int32Array(fanScratchCap); + let fanTrisScratch = new Int32Array(fanScratchCap); + function growFanScratch(need: number) { + if (need <= fanScratchCap) { + return; + } + let c = fanScratchCap; + while (c < need) { + c *= 2; + } + fanFromScratch = new Int32Array(c); + fanToScratch = new Int32Array(c); + fanTriScratch = new Int32Array(c); + fanRingScratch = new Int32Array(c); + fanTrisScratch = new Int32Array(c); + fanScratchCap = c; + } + + // Walk v's fan to extract its cyclic boundary polygon AND the matching + // cyclic ordered tris. ring[i] is the "from" vertex of the i-th tri + // (in fan order), and tris[i] = (v, ring[i], ring[(i+1) % k]) is the + // tri whose two non-v vertices are ring[i] and ring[(i+1) % k]. + // Returns null when the fan is non-manifold (duplicate from-vertex, + // closes prematurely, or fails to close). + // + // Output aliases the module-level `fanRingScratch` / `fanTrisScratch` + // buffers; the caller must consume them before the next extractFan + // call. `k` is returned explicitly since the scratch buffers may be + // oversized. + function extractFan(v: number): number { + let k = 0; + for (let n = vertHead[v]; n !== -1; n = poolNext[n]) { + k++; + } + if (k < 3) { + return -1; + } + growFanScratch(k); + + // Collect (from, to, tri) triples into parallel scratch arrays. + // The O(k^2) duplicate-from scan here and the O(k^2) cyclic walk + // below are cheap for small k and avoid per-vertex Map allocs. + let i = 0; + for (let n = vertHead[v]; n !== -1; n = poolNext[n]) { + const t = poolTri[n]; + const a = triVertsArr[t * 3]; + const b = triVertsArr[t * 3 + 1]; + const c = triVertsArr[t * 3 + 2]; + let from: number, to: number; + if (a === v) { + from = b; + to = c; + } else if (b === v) { + from = c; + to = a; + } else { + from = a; + to = b; + } + for (let j = 0; j < i; j++) { + if (fanFromScratch[j] === from) { + return -1; + } + } + fanFromScratch[i] = from; + fanToScratch[i] = to; + fanTriScratch[i] = t; + i++; + } + + const start = fanFromScratch[0]; + let cur = start; + for (let step = 0; step < k; step++) { + fanRingScratch[step] = cur; + let found = -1; + for (let j = 0; j < k; j++) { + if (fanFromScratch[j] === cur) { + found = j; + break; + } + } + if (found === -1) { + return -1; + } + fanTrisScratch[step] = fanTriScratch[found]; + const next = fanToScratch[found]; + // Premature cycle close => fan is non-manifold (multi-component). + if (next === start && step < k - 1) { + return -1; + } + cur = next; + } + if (cur !== start) { + return -1; + } + return k; + } + + // Reusable scratch for earClip's doubly-linked polygon traversal. + // Grows to the largest polygon seen and is reused across every + // worklist iteration. + let earPrevScratch = new Int32Array(16); + let earNextScratch = new Int32Array(16); + function growEarInternalScratch(n: number) { + if (n <= earPrevScratch.length) { + return; + } + let c = earPrevScratch.length; + while (c < n) { + c *= 2; + } + earPrevScratch = new Int32Array(c); + earNextScratch = new Int32Array(c); + } + + // Ear-clip a planar simple polygon. `px, py` are the projected 2D + // coordinates of the first `n` polygon vertices (in CCW order); the + // arrays may be larger than `n` (scratch-buffer aliasing is fine, + // only [0, n) is read). Writes (n - 2) * 3 polygon-relative vertex + // indices into `out[outOffset..]` and returns the number of indices + // written on success, or -1 if the polygon is degenerate / not + // simple. + // + // Strictly-collinear interior vertices (cross product == 0) are not + // considered convex ear apices and so produce slivers as side + // vertices of neighbouring ears. These are transient: any such + // vertex is itself K=1 in the same plane and the worklist removes + // it in a subsequent iteration, replacing the slivers with a clean + // re-triangulation of its updated fan. An in-earClip pre-pass that + // drops the vertex from this polygon would create a T-junction + // with the vertex's other incident tris (which still reference it), + // so we leave the cleanup to the worklist. + function earClip(px: Float64Array, py: Float64Array, n: number, out: Int32Array, outOffset: number): number { + if (n < 3) { + return -1; + } + if (n === 3) { + out[outOffset] = 0; + out[outOffset + 1] = 1; + out[outOffset + 2] = 2; + return 3; + } + + // Verify CCW orientation. By construction (right-handed basis) + // valid input polygons are CCW with positive signed area. + let area2 = 0; + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + area2 += px[i] * py[j] - px[j] * py[i]; + } + if (area2 <= 0) { + return -1; + } + + growEarInternalScratch(n); + const prev = earPrevScratch; + const next = earNextScratch; + for (let i = 0; i < n; i++) { + prev[i] = (i - 1 + n) % n; + next[i] = (i + 1) % n; + } + + function isConvex(a: number, b: number, c: number): boolean { + return (px[b] - px[a]) * (py[c] - py[a]) - (py[b] - py[a]) * (px[c] - px[a]) > 0; + } + + function inTri(p: number, a: number, b: number, c: number): boolean { + const x = px[p], + y = py[p]; + const d1 = (x - px[b]) * (py[a] - py[b]) - (px[a] - px[b]) * (y - py[b]); + const d2 = (x - px[c]) * (py[b] - py[c]) - (px[b] - px[c]) * (y - py[c]); + const d3 = (x - px[a]) * (py[c] - py[a]) - (px[c] - px[a]) * (y - py[a]); + const hasNeg = d1 < 0 || d2 < 0 || d3 < 0; + const hasPos = d1 > 0 || d2 > 0 || d3 > 0; + return !(hasNeg && hasPos); + } + + function isEar(a: number, b: number, c: number): boolean { + if (!isConvex(a, b, c)) { + return false; + } + let p = next[c]; + while (p !== a) { + if (inTri(p, a, b, c)) { + return false; + } + p = next[p]; + } + return true; + } + + let resultLen = 0; + let count = n; + let i = 0; + let stalls = 0; + + while (count > 3) { + if (stalls > count) { + return -1; + } + const p = prev[i]; + const nxt = next[i]; + if (isEar(p, i, nxt)) { + out[outOffset + resultLen++] = p; + out[outOffset + resultLen++] = i; + out[outOffset + resultLen++] = nxt; + next[p] = nxt; + prev[nxt] = p; + count--; + i = nxt; + stalls = 0; + } else { + i = next[i]; + stalls++; + } + } + out[outOffset + resultLen++] = prev[i]; + out[outOffset + resultLen++] = i; + out[outOffset + resultLen++] = next[i]; + return resultLen; + } + + // Worklist: iterative dirty-flag scheduler. Initially queue every + // vertex; on each successful removal, re-queue the ring neighbours so + // chains of K=1 / K=2 vertices collapse in a single run. + const inQueue = new Uint8Array(vertCount); + let queue = new Int32Array(Math.max(vertCount, 16)); + let queueLen = 0; + let queueHead = 0; + function pushQueue(u: number) { + if (inQueue[u]) { + return; + } + inQueue[u] = 1; + if (queueLen >= queue.length) { + const grown = new Int32Array(queue.length * 2); + grown.set(queue); + queue = grown; + } + queue[queueLen++] = u; + } + function compactQueue() { + // Reclaim consumed prefix when slack exceeds 50% (and is large + // enough to be worth the copy). Bounded by O(total pushes). + if (queueHead > 4096 && queueHead * 2 > queueLen) { + queue.copyWithin(0, queueHead, queueLen); + queueLen -= queueHead; + queueHead = 0; + } + } + for (let v = 0; v < vertCount; v++) { + inQueue[v] = 1; + queue[queueLen++] = v; + } + + // Tolerance for K=2 seam collinearity: cosine of the angle between + // (v -> a) and (v -> b) must be <= -(1 - COLLINEAR_REL_EPS), i.e. + // the two seam edges through v are nearly antiparallel (v lies on + // the segment from a to b in 3D). + const cosineMax = -1 + COLLINEAR_REL_EPS; + + // Reusable scratch for the per-arc plane descriptor. + const arcStartIdx = new Int32Array(2); + const arcPolySize = new Int32Array(2); + const arcPlaneT = new Int32Array(2); + + // Reusable per-arc scratch buffers (arcCount is always 1 or 2). + // Each slot holds the polygon vertex indices, the 2D-projected + // coordinates, and the ear-clip triangle indices for one arc. All + // grow monotonically to the largest polygon ever seen and are + // reused for every worklist iteration, so the hot loop is mostly + // allocation-free. + const arcPolyScratch: Int32Array[] = [new Int32Array(16), new Int32Array(16)]; + const arcPxScratch: Float64Array[] = [new Float64Array(16), new Float64Array(16)]; + const arcPyScratch: Float64Array[] = [new Float64Array(16), new Float64Array(16)]; + const arcEarScratch: Int32Array[] = [new Int32Array(16), new Int32Array(16)]; + const arcEarLen = new Int32Array(2); + function ensureArcScratch(g: number, polySize: number) { + if (polySize > arcPolyScratch[g].length) { + let c = arcPolyScratch[g].length; + while (c < polySize) { + c *= 2; + } + arcPolyScratch[g] = new Int32Array(c); + arcPxScratch[g] = new Float64Array(c); + arcPyScratch[g] = new Float64Array(c); + } + const earNeed = (polySize - 2) * 3; + if (earNeed > arcEarScratch[g].length) { + let c = arcEarScratch[g].length; + while (c < earNeed) { + c *= 2; + } + arcEarScratch[g] = new Int32Array(c); + } + } + + while (queueHead < queueLen) { + const v = queue[queueHead++]; + inQueue[v] = 0; + compactQueue(); + + // Cheap "fan size < 3" early-out without traversing the full + // linked list. + const h0 = vertHead[v]; + if (h0 === -1) { + continue; + } + const h1 = poolNext[h0]; + if (h1 === -1) { + continue; + } + if (poolNext[h1] === -1) { + continue; + } + + const k = extractFan(v); + if (k === -1) { + continue; + } + const ring = fanRingScratch; + const fanTris = fanTrisScratch; + + // Decide K. First check if all tris are coplanar with fanTris[0] + // (K=1 fast path). + const t0 = fanTris[0]; + const n0x = triNxArr[t0]; + const n0y = triNyArr[t0]; + const n0z = triNzArr[t0]; + const d0 = triDArr[t0]; + let allCoplanar = true; + for (let i = 1; i < k; i++) { + const t = fanTris[i]; + const dT = triDArr[t]; + const dotN = triNxArr[t] * n0x + triNyArr[t] * n0y + triNzArr[t] * n0z; + if (dotN < 1 - NORMAL_EPS || Math.abs(dT - d0) > planeEps(dT, d0)) { + allCoplanar = false; + break; + } + } + + let arcCount = 0; + if (allCoplanar) { + // K=1: single arc covering the whole ring (k vertices). + arcStartIdx[0] = 0; + arcPolySize[0] = k; + arcPlaneT[0] = t0; + arcCount = 1; + } else { + // Walk cyclically; mark transitions where plane changes vs. + // the previous tri in fan order. Exactly 2 transitions => + // K=2 candidate. + let nTransitions = 0; + let i1 = -1; + let i2 = -1; + for (let i = 0; i < k; i++) { + const t = fanTris[i]; + const prevT = fanTris[(i - 1 + k) % k]; + const nx1 = triNxArr[t]; + const ny1 = triNyArr[t]; + const nz1 = triNzArr[t]; + const d1 = triDArr[t]; + const nx0 = triNxArr[prevT]; + const ny0 = triNyArr[prevT]; + const nz0 = triNzArr[prevT]; + const dPrev = triDArr[prevT]; + const dotN = nx1 * nx0 + ny1 * ny0 + nz1 * nz0; + if (dotN < 1 - NORMAL_EPS || Math.abs(d1 - dPrev) > planeEps(d1, dPrev)) { + nTransitions++; + if (nTransitions === 1) { + i1 = i; + } else if (nTransitions === 2) { + i2 = i; + } else { + break; + } + } + } + if (nTransitions !== 2) { + continue; + } + + // K=2 collinearity: ring[i1], v, ring[i2] must be collinear + // with v between (cosine of va-vb angle near -1). + const a = ring[i1]; + const b = ring[i2]; + const ax = positions[a * 3] - positions[v * 3]; + const ay = positions[a * 3 + 1] - positions[v * 3 + 1]; + const az = positions[a * 3 + 2] - positions[v * 3 + 2]; + const bx = positions[b * 3] - positions[v * 3]; + const by = positions[b * 3 + 1] - positions[v * 3 + 1]; + const bz = positions[b * 3 + 2] - positions[v * 3 + 2]; + const lenA = Math.sqrt(ax * ax + ay * ay + az * az); + const lenB = Math.sqrt(bx * bx + by * by + bz * bz); + if (lenA < 1e-12 || lenB < 1e-12) { + continue; + } + const cosine = (ax * bx + ay * by + az * bz) / (lenA * lenB); + if (cosine > cosineMax) { + continue; + } + + // Two arcs: arc 0 covers tris[i1..i2-1] (mA tris, mA+1 polygon + // verts ring[i1..i2]); arc 1 covers tris[i2..i1-1] cyclically + // (mB tris, mB+1 polygon verts). + const mA = i2 - i1; + const mB = k - mA; + arcStartIdx[0] = i1; + arcPolySize[0] = mA + 1; + arcPlaneT[0] = fanTris[i1]; + arcStartIdx[1] = i2; + arcPolySize[1] = mB + 1; + arcPlaneT[1] = fanTris[i2]; + arcCount = 2; + } + + // Build polygons and triangulations into the reusable per-arc + // scratch slots. Bail (without committing) if any arc fails to + // triangulate. + let allArcsOk = true; + for (let g = 0; g < arcCount; g++) { + const polySize = arcPolySize[g]; + const startIdx = arcStartIdx[g]; + const planeT = arcPlaneT[g]; + ensureArcScratch(g, polySize); + const poly = arcPolyScratch[g]; + for (let j = 0; j < polySize; j++) { + poly[j] = ring[(startIdx + j) % k]; + } + + const nx = triNxArr[planeT]; + const ny = triNyArr[planeT]; + const nz = triNzArr[planeT]; + const [tx, ty, tz, bx, by, bz] = buildBasis(nx, ny, nz); + const px = arcPxScratch[g]; + const py = arcPyScratch[g]; + for (let j = 0; j < polySize; j++) { + const u = poly[j]; + const x = positions[u * 3]; + const y = positions[u * 3 + 1]; + const z = positions[u * 3 + 2]; + px[j] = x * tx + y * ty + z * tz; + py[j] = x * bx + y * by + z * bz; + } + + const earCount = earClip(px, py, polySize, arcEarScratch[g], 0); + if (earCount !== (polySize - 2) * 3) { + allArcsOk = false; + break; + } + arcEarLen[g] = earCount; + } + if (!allArcsOk) { + continue; + } + + // Commit: walk v's linked-list of incident tris in place. For each + // tri, mark it dead, unlink it from each non-v vertex's list, and + // free v's own node. Iteration is safe because we only mutate + // OTHER vertices' lists during the walk. + let cur = vertHead[v]; + while (cur !== -1) { + const t = poolTri[cur]; + triAliveArr[t] = 0; + for (let j = 0; j < 3; j++) { + const u = triVertsArr[t * 3 + j]; + if (u === v) { + continue; + } + removeTriFromVert(u, t); + } + const nxt = poolNext[cur]; + freeNode(cur); + cur = nxt; + } + vertHead[v] = -1; + + // Append new tris per arc, each carrying its arc's plane. + for (let g = 0; g < arcCount; g++) { + const planeT = arcPlaneT[g]; + const nx = triNxArr[planeT]; + const ny = triNyArr[planeT]; + const nz = triNzArr[planeT]; + const d = triDArr[planeT]; + const earIdx = arcEarScratch[g]; + const earIdxLen = arcEarLen[g]; + const poly = arcPolyScratch[g]; + for (let i = 0; i < earIdxLen; i += 3) { + const ua = poly[earIdx[i]]; + const ub = poly[earIdx[i + 1]]; + const uc = poly[earIdx[i + 2]]; + ensureTriCap(); + const newT = triCount++; + triVertsArr[newT * 3] = ua; + triVertsArr[newT * 3 + 1] = ub; + triVertsArr[newT * 3 + 2] = uc; + triNxArr[newT] = nx; + triNyArr[newT] = ny; + triNzArr[newT] = nz; + triDArr[newT] = d; + triAliveArr[newT] = 1; + addTriToVert(ua, newT); + addTriToVert(ub, newT); + addTriToVert(uc, newT); + } + } + + // Re-queue every ring vertex: each may now satisfy K=1 or K=2 in + // its updated fan, allowing the collapse to propagate along long + // collinear chains in a single sweep. + for (let i = 0; i < k; i++) { + pushQueue(ring[i]); + } + } + + // Compact: drop dead tris and unused vertices, remap indices. + const usedVerts = new Uint8Array(vertCount); + let outTriCount = 0; + for (let t = 0; t < triCount; t++) { + if (triAliveArr[t] === 0) { + continue; + } + outTriCount++; + usedVerts[triVertsArr[t * 3]] = 1; + usedVerts[triVertsArr[t * 3 + 1]] = 1; + usedVerts[triVertsArr[t * 3 + 2]] = 1; + } + + let outVertCount = 0; + const vertRemap = new Int32Array(vertCount); + for (let v = 0; v < vertCount; v++) { + if (usedVerts[v] === 1) { + vertRemap[v] = outVertCount++; + } else { + vertRemap[v] = -1; + } + } + + const outPositions = new Float32Array(outVertCount * 3); + for (let v = 0; v < vertCount; v++) { + if (usedVerts[v] === 0) { + continue; + } + const o = vertRemap[v] * 3; + outPositions[o] = positions[v * 3]; + outPositions[o + 1] = positions[v * 3 + 1]; + outPositions[o + 2] = positions[v * 3 + 2]; + } + + const outIndices = new Uint32Array(outTriCount * 3); + let oi = 0; + for (let t = 0; t < triCount; t++) { + if (triAliveArr[t] === 0) { + continue; + } + outIndices[oi++] = vertRemap[triVertsArr[t * 3]]; + outIndices[oi++] = vertRemap[triVertsArr[t * 3 + 1]]; + outIndices[oi++] = vertRemap[triVertsArr[t * 3 + 2]]; + } + + return { positions: outPositions, indices: outIndices }; +} + +export { coplanarMerge }; diff --git a/packages/splat-transform/src/utils/voxel/filterCluster.ts b/packages/splat-transform/src/utils/voxel/filterCluster.ts new file mode 100644 index 0000000..56bc389 --- /dev/null +++ b/packages/splat-transform/src/utils/voxel/filterCluster.ts @@ -0,0 +1,1015 @@ +import { ColIdx, SplatData } from '../../SplatData.js'; +import { logger } from '../Logger.js'; +import { + alignGridBounds, + popcount, + BLOCK_EMPTY, + BLOCK_MIXED, + BLOCK_SOLID, + BlockMaskBuffer, + extentsFromQuatScale, + readBlockType, + SOLID_HI, + SOLID_LO, + SparseVoxelGrid, + writeBlockType, + type Bounds, +} from './common.js'; +import { cpuVoxelize, gpuVoxelize } from './voxelize.js'; + +export interface FilterClusterOptions { + voxelResolution?: number; + opacityCutoff?: number; + minContribution?: number; + seed?: { x: number; y: number; z: number }; +} + +export interface FilterClusterRuntimeOptions { + backend?: 'cpu' | 'gpu'; + cpuWorkerCount?: number; + box?: { minCorner: [number, number, number]; maxCorner: [number, number, number] }; +} + +interface BlockLookup { + solidSet: Set; + mixedMap: Map; + masks: Uint32Array; +} + +interface BlockGridParams { + gridMinX: number; + gridMinY: number; + gridMinZ: number; + blockSize: number; + voxelResolution: number; + numBlocksX: number; + numBlocksY: number; + numBlocksZ: number; + strideY: number; + strideZ: number; +} + +interface VoxelCluster { + grid: SparseVoxelGrid; + voxelCount: number; +} + +interface GaussianSelectionResult { + selectedIndices: number[]; + aborted: boolean; +} + +const QUEUE_CAP_MAX = 1 << 30; +const FALLBACK_MIN_GAUSSIAN_RATIO = 0.3; +const FALLBACK_CANDIDATE_LIMIT = 5; + +function countOccupiedVoxels(buffer: BlockMaskBuffer): number { + let count = buffer.getSolidBlocks().length * 64; + const mixed = buffer.getMixedBlocks(); + for (let i = 0; i < mixed.blockIdx.length; i++) { + count += popcount(mixed.masks[i * 2]) + popcount(mixed.masks[i * 2 + 1]); + } + return count; +} + +function makeFaceMask(axis: 0 | 1 | 2, value: number): [number, number] { + let lo = 0; + let hi = 0; + for (let z = 0; z < 4; z++) { + for (let y = 0; y < 4; y++) { + for (let x = 0; x < 4; x++) { + if ((axis === 0 && x !== value) || (axis === 1 && y !== value) || (axis === 2 && z !== value)) { + continue; + } + const bit = x + y * 4 + z * 16; + if (bit < 32) { + lo = (lo | (1 << bit)) >>> 0; + } else { + hi = (hi | (1 << (bit - 32))) >>> 0; + } + } + } + } + return [lo >>> 0, hi >>> 0]; +} + +const FACE_MASKS: Array<[number, number]> = [ + makeFaceMask(0, 0), + makeFaceMask(0, 3), + makeFaceMask(1, 0), + makeFaceMask(1, 3), + makeFaceMask(2, 0), + makeFaceMask(2, 3), +]; + +function voxelMask(ix: number, iy: number, iz: number): [number, number] { + const bit = (ix & 3) + ((iy & 3) << 2) + ((iz & 3) << 4); + return bit < 32 ? [(1 << bit) >>> 0, 0] : [0, (1 << (bit - 32)) >>> 0]; +} + +function buildBlockLookup(buffer: BlockMaskBuffer): BlockLookup { + const solidSet = new Set(); + const solid = buffer.getSolidBlocks(); + for (let i = 0; i < solid.length; i++) { + solidSet.add(solid[i]); + } + const mixed = buffer.getMixedBlocks(); + const mixedMap = new Map(); + for (let i = 0; i < mixed.blockIdx.length; i++) { + mixedMap.set(mixed.blockIdx[i], i); + } + return { solidSet, mixedMap, masks: mixed.masks }; +} + +function isCenterInOccupiedVoxel( + px: number, + py: number, + pz: number, + grid: BlockGridParams, + lookup: BlockLookup, +): boolean { + const bx = Math.floor((px - grid.gridMinX) / grid.blockSize); + const by = Math.floor((py - grid.gridMinY) / grid.blockSize); + const bz = Math.floor((pz - grid.gridMinZ) / grid.blockSize); + if (bx < 0 || bx >= grid.numBlocksX || by < 0 || by >= grid.numBlocksY || bz < 0 || bz >= grid.numBlocksZ) { + return false; + } + const blockIdx = bx + by * grid.strideY + bz * grid.strideZ; + if (lookup.solidSet.has(blockIdx)) { + return true; + } + const mixedIdx = lookup.mixedMap.get(blockIdx); + if (mixedIdx === undefined) { + return false; + } + const lx = Math.floor((px - grid.gridMinX - bx * grid.blockSize) / grid.voxelResolution) & 3; + const ly = Math.floor((py - grid.gridMinY - by * grid.blockSize) / grid.voxelResolution) & 3; + const lz = Math.floor((pz - grid.gridMinZ - bz * grid.blockSize) / grid.voxelResolution) & 3; + const bitIdx = lx + ly * 4 + lz * 16; + const word = bitIdx < 32 ? lookup.masks[mixedIdx * 2] : lookup.masks[mixedIdx * 2 + 1]; + return ((word >>> (bitIdx & 31)) & 1) !== 0; +} + +function gaussianAtVoxelCenter( + px: number, + py: number, + pz: number, + qx: number, + qy: number, + qz: number, + qw: number, + sx: number, + sy: number, + sz: number, + opacity: number, + vx: number, + vy: number, + vz: number, +): number { + const dx = vx - px; + const dy = vy - py; + const dz = vz - pz; + const iqx = -qx; + const iqy = -qy; + const iqz = -qz; + const tx = 2 * (iqy * dz - iqz * dy); + const ty = 2 * (iqz * dx - iqx * dz); + const tz = 2 * (iqx * dy - iqy * dx); + const lx = dx + qw * tx + (iqy * tz - iqz * ty); + const ly = dy + qw * ty + (iqz * tx - iqx * tz); + const lz = dz + qw * tz + (iqx * ty - iqy * tx); + const isx = sx > 1e-8 ? 1 / sx : 1e8; + const isy = sy > 1e-8 ? 1 / sy : 1e8; + const isz = sz > 1e-8 ? 1 / sz : 1e8; + const d2 = (lx * isx) ** 2 + (ly * isy) ** 2 + (lz * isz) ** 2; + return opacity * Math.exp(-0.5 * d2); +} + +function gaussianContributesToVoxels( + i: number, + data: SplatData, + extents: Float32Array, + grid: BlockGridParams, + lookup: BlockLookup, + minContribution: number, + minHits = 1, +): boolean { + const table = data.table; + const px = table[ColIdx.x][i]; + const py = table[ColIdx.y][i]; + const pz = table[ColIdx.z][i]; + const ex = extents[i * 3]; + const ey = extents[i * 3 + 1]; + const ez = extents[i * 3 + 2]; + const minBx = Math.max(0, Math.floor((px - ex - grid.gridMinX) / grid.blockSize)); + const maxBx = Math.min(grid.numBlocksX - 1, Math.floor((px + ex - grid.gridMinX) / grid.blockSize)); + const minBy = Math.max(0, Math.floor((py - ey - grid.gridMinY) / grid.blockSize)); + const maxBy = Math.min(grid.numBlocksY - 1, Math.floor((py + ey - grid.gridMinY) / grid.blockSize)); + const minBz = Math.max(0, Math.floor((pz - ez - grid.gridMinZ) / grid.blockSize)); + const maxBz = Math.min(grid.numBlocksZ - 1, Math.floor((pz + ez - grid.gridMinZ) / grid.blockSize)); + const qx = table[ColIdx.qx][i]; + const qy = table[ColIdx.qy][i]; + const qz = table[ColIdx.qz][i]; + const qw = table[ColIdx.qw][i]; + const sx = table[ColIdx.sx][i]; + const sy = table[ColIdx.sy][i]; + const sz = table[ColIdx.sz][i]; + const opacity = table[ColIdx.a][i]; + let hits = 0; + for (let bz = minBz; bz <= maxBz; bz++) { + for (let by = minBy; by <= maxBy; by++) { + for (let bx = minBx; bx <= maxBx; bx++) { + const blockIdx = bx + by * grid.strideY + bz * grid.strideZ; + const solid = lookup.solidSet.has(blockIdx); + const mixedIdx = solid ? undefined : lookup.mixedMap.get(blockIdx); + if (!solid && mixedIdx === undefined) { + continue; + } + const lo = solid ? 0xffff_ffff : lookup.masks[mixedIdx! * 2]; + const hi = solid ? 0xffff_ffff : lookup.masks[mixedIdx! * 2 + 1]; + const blockMinX = grid.gridMinX + bx * grid.blockSize; + const blockMinY = grid.gridMinY + by * grid.blockSize; + const blockMinZ = grid.gridMinZ + bz * grid.blockSize; + for (let lz = 0; lz < 4; lz++) { + const word = lz < 2 ? lo : hi; + const zBitBase = (lz & 1) * 16; + const vz = blockMinZ + (lz + 0.5) * grid.voxelResolution; + for (let ly = 0; ly < 4; ly++) { + const bitBase = zBitBase + ly * 4; + const vy = blockMinY + (ly + 0.5) * grid.voxelResolution; + for (let lx = 0; lx < 4; lx++) { + if (((word >>> (bitBase + lx)) & 1) === 0) { + continue; + } + const vx = blockMinX + (lx + 0.5) * grid.voxelResolution; + if ( + gaussianAtVoxelCenter(px, py, pz, qx, qy, qz, qw, sx, sy, sz, opacity, vx, vy, vz) >= + minContribution + ) { + if (++hits >= minHits) { + return true; + } + } + } + } + } + } + } + } + return false; +} + +function twoLevelBFS( + blocked: SparseVoxelGrid, + blockSeeds: number[], + voxelSeeds: Array<{ ix: number; iy: number; iz: number }>, + nx: number, + ny: number, + nz: number, + visitedAccumulator?: SparseVoxelGrid, +): VoxelCluster { + const visited = new SparseVoxelGrid(nx, ny, nz); + let voxelCount = 0; + const nbx = nx >> 2; + const nby = ny >> 2; + const bStride = nbx * nby; + const blockedTypes = blocked.types; + const blockedMasks = blocked.masks; + const visitedTypes = visited.types; + const visitedMasks = visited.masks; + + let bqCap = 1 << 14; + let bq = new Uint32Array(bqCap); + let bqMask = bqCap - 1; + let bqHead = 0; + let bqTail = 0; + let bqSize = 0; + + let vqCap = 1 << 14; + let vqX = new Uint32Array(vqCap); + let vqY = new Uint32Array(vqCap); + let vqZ = new Uint32Array(vqCap); + let vqMask = vqCap - 1; + let vqHead = 0; + let vqTail = 0; + let vqSize = 0; + + function growBlockQueue() { + if (bqCap >= QUEUE_CAP_MAX) { + throw new Error( + `filterCluster: block queue exceeded ${QUEUE_CAP_MAX}; try a coarser filterCluster.voxelResolution`, + ); + } + const grown = new Uint32Array(bqCap * 2); + for (let i = 0; i < bqSize; i++) { + grown[i] = bq[(bqHead + i) & bqMask]; + } + bq = grown; + bqCap = grown.length; + bqMask = bqCap - 1; + bqHead = 0; + bqTail = bqSize; + } + + function growVoxelQueue() { + if (vqCap >= QUEUE_CAP_MAX) { + throw new Error( + `filterCluster: voxel queue exceeded ${QUEUE_CAP_MAX}; try a coarser filterCluster.voxelResolution`, + ); + } + const grownX = new Uint32Array(vqCap * 2); + const grownY = new Uint32Array(vqCap * 2); + const grownZ = new Uint32Array(vqCap * 2); + for (let i = 0; i < vqSize; i++) { + const src = (vqHead + i) & vqMask; + grownX[i] = vqX[src]; + grownY[i] = vqY[src]; + grownZ[i] = vqZ[src]; + } + vqX = grownX; + vqY = grownY; + vqZ = grownZ; + vqCap = grownX.length; + vqMask = vqCap - 1; + vqHead = 0; + vqTail = vqSize; + } + + function enqueueBlock(blockIdx: number) { + if (bqSize >= bqCap) { + growBlockQueue(); + } + bq[bqTail] = blockIdx; + bqTail = (bqTail + 1) & bqMask; + bqSize++; + } + + function enqueueVoxel(ix: number, iy: number, iz: number) { + if (vqSize >= vqCap) { + growVoxelQueue(); + } + vqX[vqTail] = ix; + vqY[vqTail] = iy; + vqZ[vqTail] = iz; + vqTail = (vqTail + 1) & vqMask; + vqSize++; + } + + function tryFillBlock(blockIdx: number): boolean { + if (readBlockType(blockedTypes, blockIdx) !== BLOCK_EMPTY) { + return false; + } + if (readBlockType(visitedTypes, blockIdx) !== BLOCK_EMPTY) { + return false; + } + writeBlockType(visitedTypes, blockIdx, BLOCK_SOLID); + if (visitedAccumulator) { + visitedAccumulator.orBlock(blockIdx, SOLID_LO, SOLID_HI); + } + voxelCount += 64; + enqueueBlock(blockIdx); + return true; + } + + function enqueueVisitedMaskVoxels(blockIdx: number, bx: number, by: number, bz: number, lo: number, hi: number) { + if ((lo | hi) === 0) { + return; + } + const baseX = bx << 2; + const baseY = by << 2; + const baseZ = bz << 2; + let bt = readBlockType(visitedTypes, blockIdx); + let slot = -1; + let oldLo = 0; + let oldHi = 0; + if (bt === BLOCK_SOLID) { + return; + } + if (bt === BLOCK_MIXED) { + slot = visitedMasks.slot(blockIdx); + oldLo = visitedMasks.lo[slot]; + oldHi = visitedMasks.hi[slot]; + } else { + writeBlockType(visitedTypes, blockIdx, BLOCK_MIXED); + visitedMasks.set(blockIdx, 0, 0); + slot = visitedMasks.slot(blockIdx); + bt = BLOCK_MIXED; + } + const newLo = (lo & ~oldLo) >>> 0; + const newHi = (hi & ~oldHi) >>> 0; + if ((newLo | newHi) === 0) { + return; + } + if (visitedAccumulator) { + visitedAccumulator.orBlock(blockIdx, newLo, newHi); + } + voxelCount += popcount(newLo) + popcount(newHi); + visitedMasks.lo[slot] = (oldLo | newLo) >>> 0; + visitedMasks.hi[slot] = (oldHi | newHi) >>> 0; + if (visitedMasks.lo[slot] === SOLID_LO && visitedMasks.hi[slot] === SOLID_HI) { + visitedMasks.removeAt(slot); + writeBlockType(visitedTypes, blockIdx, BLOCK_SOLID); + } + let bits = newLo; + while (bits) { + const bit = 31 - Math.clz32(bits & -bits); + enqueueVoxel(baseX + (bit & 3), baseY + ((bit >> 2) & 3), baseZ + (bit >> 4)); + bits &= bits - 1; + } + bits = newHi; + while (bits) { + const bit = 31 - Math.clz32(bits & -bits); + const bi = bit + 32; + enqueueVoxel(baseX + (bi & 3), baseY + ((bi >> 2) & 3), baseZ + (bi >> 4)); + bits &= bits - 1; + } + } + + function enqueueFaceVoxels(blockIdx: number, face: number, bx: number, by: number, bz: number) { + if (readBlockType(visitedTypes, blockIdx) === BLOCK_SOLID) { + return; + } + const blockedSlot = blockedMasks.slot(blockIdx); + const visitedSlot = readBlockType(visitedTypes, blockIdx) === BLOCK_MIXED ? visitedMasks.slot(blockIdx) : -1; + const [faceLo, faceHi] = FACE_MASKS[face]; + const freeLo = + (faceLo & ~blockedMasks.lo[blockedSlot] & ~(visitedSlot >= 0 ? visitedMasks.lo[visitedSlot] : 0)) >>> 0; + const freeHi = + (faceHi & ~blockedMasks.hi[blockedSlot] & ~(visitedSlot >= 0 ? visitedMasks.hi[visitedSlot] : 0)) >>> 0; + enqueueVisitedMaskVoxels(blockIdx, bx, by, bz, freeLo, freeHi); + } + + function processBlock(blockIdx: number) { + const bx = blockIdx % nbx; + const byBz = (blockIdx / nbx) | 0; + const by = byBz % nby; + const bz = (byBz / nby) | 0; + if (bx > 0) { + const n = blockIdx - 1; + const bt = readBlockType(blockedTypes, n); + if (bt === BLOCK_EMPTY) { + tryFillBlock(n); + } else if (bt === BLOCK_MIXED) { + enqueueFaceVoxels(n, 1, bx - 1, by, bz); + } + } + if (bx < nbx - 1) { + const n = blockIdx + 1; + const bt = readBlockType(blockedTypes, n); + if (bt === BLOCK_EMPTY) { + tryFillBlock(n); + } else if (bt === BLOCK_MIXED) { + enqueueFaceVoxels(n, 0, bx + 1, by, bz); + } + } + if (by > 0) { + const n = blockIdx - nbx; + const bt = readBlockType(blockedTypes, n); + if (bt === BLOCK_EMPTY) { + tryFillBlock(n); + } else if (bt === BLOCK_MIXED) { + enqueueFaceVoxels(n, 3, bx, by - 1, bz); + } + } + if (by < nby - 1) { + const n = blockIdx + nbx; + const bt = readBlockType(blockedTypes, n); + if (bt === BLOCK_EMPTY) { + tryFillBlock(n); + } else if (bt === BLOCK_MIXED) { + enqueueFaceVoxels(n, 2, bx, by + 1, bz); + } + } + if (bz > 0) { + const n = blockIdx - bStride; + const bt = readBlockType(blockedTypes, n); + if (bt === BLOCK_EMPTY) { + tryFillBlock(n); + } else if (bt === BLOCK_MIXED) { + enqueueFaceVoxels(n, 5, bx, by, bz - 1); + } + } + if (bz < (nz >> 2) - 1) { + const n = blockIdx + bStride; + const bt = readBlockType(blockedTypes, n); + if (bt === BLOCK_EMPTY) { + tryFillBlock(n); + } else if (bt === BLOCK_MIXED) { + enqueueFaceVoxels(n, 4, bx, by, bz + 1); + } + } + } + + function processVoxel(ix: number, iy: number, iz: number) { + function visit(x: number, y: number, z: number) { + if (x < 0 || x >= nx || y < 0 || y >= ny || z < 0 || z >= nz) { + return; + } + const blockIdx = (x >> 2) + (y >> 2) * nbx + (z >> 2) * bStride; + const bt = readBlockType(blockedTypes, blockIdx); + if (bt === BLOCK_SOLID) { + return; + } + if (bt === BLOCK_EMPTY) { + tryFillBlock(blockIdx); + return; + } + const blockedSlot = blockedMasks.slot(blockIdx); + const bit = (x & 3) + ((y & 3) << 2) + ((z & 3) << 4); + const blockedWord = bit < 32 ? blockedMasks.lo[blockedSlot] : blockedMasks.hi[blockedSlot]; + if (((blockedWord >>> (bit & 31)) & 1) !== 0) { + return; + } + const lo = bit < 32 ? (1 << bit) >>> 0 : 0; + const hi = bit >= 32 ? (1 << (bit - 32)) >>> 0 : 0; + enqueueVisitedMaskVoxels(blockIdx, x >> 2, y >> 2, z >> 2, lo, hi); + } + visit(ix - 1, iy, iz); + visit(ix + 1, iy, iz); + visit(ix, iy - 1, iz); + visit(ix, iy + 1, iz); + visit(ix, iy, iz - 1); + visit(ix, iy, iz + 1); + } + + for (const blockIdx of blockSeeds) { + tryFillBlock(blockIdx); + } + for (const seed of voxelSeeds) { + const [lo, hi] = voxelMask(seed.ix, seed.iy, seed.iz); + enqueueVisitedMaskVoxels( + (seed.ix >> 2) + (seed.iy >> 2) * nbx + (seed.iz >> 2) * bStride, + seed.ix >> 2, + seed.iy >> 2, + seed.iz >> 2, + lo, + hi, + ); + } + while (bqSize > 0 || vqSize > 0) { + if (bqSize > 0) { + const blockIdx = bq[bqHead]; + bqHead = (bqHead + 1) & bqMask; + bqSize--; + processBlock(blockIdx); + } else { + const ix = vqX[vqHead]; + const iy = vqY[vqHead]; + const iz = vqZ[vqHead]; + vqHead = (vqHead + 1) & vqMask; + vqSize--; + processVoxel(ix, iy, iz); + } + } + return { grid: visited, voxelCount }; +} + +function floodClusterFromSeed( + blocked: SparseVoxelGrid, + nx: number, + ny: number, + nz: number, + seedIx: number, + seedIy: number, + seedIz: number, +): VoxelCluster | null { + if (blocked.getVoxel(seedIx, seedIy, seedIz)) { + const nearest = SparseVoxelGrid.findNearestFreeCell(blocked, seedIx, seedIy, seedIz, Math.max(nx, ny, nz)); + if (!nearest) { + return null; + } + seedIx = nearest.ix; + seedIy = nearest.iy; + seedIz = nearest.iz; + logger.warn(`filterCluster seed is empty; using nearest occupied voxel (${seedIx}, ${seedIy}, ${seedIz})`); + } + const seedBlockIdx = (seedIx >> 2) + (seedIy >> 2) * blocked.nbx + (seedIz >> 2) * blocked.bStride; + const seedBlockType = readBlockType(blocked.types, seedBlockIdx); + return twoLevelBFS( + blocked, + seedBlockType === BLOCK_EMPTY ? [seedBlockIdx] : [], + seedBlockType === BLOCK_EMPTY ? [] : [{ ix: seedIx, iy: seedIy, iz: seedIz }], + nx, + ny, + nz, + ); +} + +function getProcessedMask(processed: SparseVoxelGrid, blockIdx: number): [number, number] { + const blockType = readBlockType(processed.types, blockIdx); + if (blockType === BLOCK_EMPTY) { + return [0, 0]; + } + if (blockType === BLOCK_SOLID) { + return [SOLID_LO, SOLID_HI]; + } + const slot = processed.masks.slot(blockIdx); + return [processed.masks.lo[slot], processed.masks.hi[slot]]; +} + +function insertCandidate(candidates: VoxelCluster[], candidate: VoxelCluster, limit: number) { + candidates.push(candidate); + candidates.sort((a, b) => b.voxelCount - a.voxelCount); + if (candidates.length > limit) { + const removed = candidates.pop(); + removed?.grid.releaseStorage(); + } +} + +function findLargestVoxelClusterCandidates( + buffer: BlockMaskBuffer, + blocked: SparseVoxelGrid, + nx: number, + ny: number, + nz: number, + limit: number, + initialProcessed?: SparseVoxelGrid, + initialComponentCount = 0, +): { candidates: VoxelCluster[]; componentCount: number } { + const candidates: VoxelCluster[] = []; + const processed = initialProcessed ?? new SparseVoxelGrid(nx, ny, nz); + const nbx = nx >> 2; + const nby = ny >> 2; + const bStride = nbx * nby; + let componentCount = initialComponentCount; + + function floodFromMask(blockIdx: number, lo: number, hi: number) { + while ((lo | hi) !== 0) { + const bit = lo ? 31 - Math.clz32(lo & -lo) : 31 - Math.clz32(hi & -hi) + 32; + const bx = blockIdx % nbx; + const byBz = (blockIdx / nbx) | 0; + const by = byBz % nby; + const bz = (blockIdx / bStride) | 0; + const seedIx = (bx << 2) + (bit & 3); + const seedIy = (by << 2) + ((bit >> 2) & 3); + const seedIz = (bz << 2) + (bit >> 4); + const seedBlockType = readBlockType(blocked.types, blockIdx); + const floodResult = twoLevelBFS( + blocked, + seedBlockType === BLOCK_EMPTY ? [blockIdx] : [], + seedBlockType === BLOCK_EMPTY ? [] : [{ ix: seedIx, iy: seedIy, iz: seedIz }], + nx, + ny, + nz, + processed, + ); + componentCount++; + insertCandidate(candidates, floodResult, limit); + const [processedLo, processedHi] = getProcessedMask(processed, blockIdx); + lo = (lo & ~processedLo) >>> 0; + hi = (hi & ~processedHi) >>> 0; + } + } + + const solidBlocks = buffer.getSolidBlocks(); + for (let i = 0; i < solidBlocks.length; i++) { + const blockIdx = solidBlocks[i]; + const [processedLo, processedHi] = getProcessedMask(processed, blockIdx); + floodFromMask(blockIdx, (SOLID_LO & ~processedLo) >>> 0, (SOLID_HI & ~processedHi) >>> 0); + } + + const mixed = buffer.getMixedBlocks(); + for (let i = 0; i < mixed.blockIdx.length; i++) { + const blockIdx = mixed.blockIdx[i]; + const [processedLo, processedHi] = getProcessedMask(processed, blockIdx); + floodFromMask( + blockIdx, + (mixed.masks[i * 2] & ~processedLo) >>> 0, + (mixed.masks[i * 2 + 1] & ~processedHi) >>> 0, + ); + } + + return { candidates, componentCount }; +} + +function cloneRows(data: SplatData, rows: number[]): SplatData { + const out = new SplatData().init(rows.length, data.shDegree); + for (let c = 0; c < data.table.length; c++) { + const src = data.table[c]; + const dst = out.table[c]; + for (let i = 0; i < rows.length; i++) { + dst[i] = src[rows[i]]; + } + } + return out; +} + +export async function filterCluster( + data: SplatData, + options: FilterClusterOptions = {}, + runtime: FilterClusterRuntimeOptions = {}, +): Promise { + const backend = runtime.backend ?? 'gpu'; + const voxelResolution = options.voxelResolution ?? 1.0; + const opacityCutoff = options.opacityCutoff ?? 0.999; + const minContribution = options.minContribution ?? 0.1; + const hasExplicitSeed = options.seed !== undefined; + const seed = options.seed ?? { x: 0, y: 0, z: 0 }; + const box = runtime.box; + if (!Number.isFinite(voxelResolution) || voxelResolution <= 0) { + throw new Error(`filterCluster: voxelResolution must be > 0, got ${voxelResolution}`); + } + if (!Number.isFinite(opacityCutoff) || opacityCutoff < 0 || opacityCutoff > 1) { + throw new Error(`filterCluster: opacityCutoff must be in [0, 1], got ${opacityCutoff}`); + } + if (!Number.isFinite(minContribution) || minContribution < 0) { + throw new Error(`filterCluster: minContribution must be >= 0, got ${minContribution}`); + } + if (data.counts === 0) { + return data; + } + const table = data.table; + const xCol = table[ColIdx.x]; + const yCol = table[ColIdx.y]; + const zCol = table[ColIdx.z]; + const sxCol = table[ColIdx.sx]; + const syCol = table[ColIdx.sy]; + const szCol = table[ColIdx.sz]; + const qxCol = table[ColIdx.qx]; + const qyCol = table[ColIdx.qy]; + const qzCol = table[ColIdx.qz]; + const qwCol = table[ColIdx.qw]; + const aCol = table[ColIdx.a]; + const extents = new Float32Array(data.counts * 3); + const sceneBounds: Bounds = { + min: { x: Infinity, y: Infinity, z: Infinity }, + max: { x: -Infinity, y: -Infinity, z: -Infinity }, + }; + for (let i = 0; i < data.counts; i++) { + const e = extentsFromQuatScale(sxCol[i], syCol[i], szCol[i], qxCol[i], qyCol[i], qzCol[i], qwCol[i], aCol[i]); + extents[i * 3] = e.ex; + extents[i * 3 + 1] = e.ey; + extents[i * 3 + 2] = e.ez; + sceneBounds.min.x = Math.min(sceneBounds.min.x, xCol[i] - e.ex); + sceneBounds.min.y = Math.min(sceneBounds.min.y, yCol[i] - e.ey); + sceneBounds.min.z = Math.min(sceneBounds.min.z, zCol[i] - e.ez); + sceneBounds.max.x = Math.max(sceneBounds.max.x, xCol[i] + e.ex); + sceneBounds.max.y = Math.max(sceneBounds.max.y, yCol[i] + e.ey); + sceneBounds.max.z = Math.max(sceneBounds.max.z, zCol[i] + e.ez); + } + let gridBounds = alignGridBounds(sceneBounds, voxelResolution); + if (box) { + gridBounds = alignGridBounds( + { + min: { + x: Math.max(gridBounds.min.x, box.minCorner[0]), + y: Math.max(gridBounds.min.y, box.minCorner[1]), + z: Math.max(gridBounds.min.z, box.minCorner[2]), + }, + max: { + x: Math.min(gridBounds.max.x, box.maxCorner[0]), + y: Math.min(gridBounds.max.y, box.maxCorner[1]), + z: Math.min(gridBounds.max.z, box.maxCorner[2]), + }, + }, + voxelResolution, + ); + if ( + gridBounds.min.x >= gridBounds.max.x || + gridBounds.min.y >= gridBounds.max.y || + gridBounds.min.z >= gridBounds.max.z + ) { + logger.warn('filterCluster: box does not overlap scene bounds, returning empty data'); + return cloneRows(data, []); + } + } + const maxGridExtent = 4096 * voxelResolution; + function clampAxis(min: number, max: number, seedValue: number) { + if (max - min <= maxGridExtent) { + return { min, max }; + } + const half = maxGridExtent * 0.5; + const center = Math.max(min + half, Math.min(seedValue, max - half)); + return { min: center - half, max: center + half }; + } + const cx = clampAxis(gridBounds.min.x, gridBounds.max.x, seed.x); + const cy = clampAxis(gridBounds.min.y, gridBounds.max.y, seed.y); + const cz = clampAxis(gridBounds.min.z, gridBounds.max.z, seed.z); + gridBounds = { + min: { x: cx.min, y: cy.min, z: cz.min }, + max: { x: cx.max, y: cy.max, z: cz.max }, + }; + const blockSize = 4 * voxelResolution; + const nbx = Math.round((gridBounds.max.x - gridBounds.min.x) / blockSize); + const nby = Math.round((gridBounds.max.y - gridBounds.min.y) / blockSize); + const nbz = Math.round((gridBounds.max.z - gridBounds.min.z) / blockSize); + const nx = nbx * 4; + const ny = nby * 4; + const nz = nbz * 4; + const cpuOptions: { workerCount?: number; alphaThreshold: number } = { alphaThreshold: 0 }; + if (runtime.cpuWorkerCount !== undefined) { + cpuOptions.workerCount = runtime.cpuWorkerCount; + } + logger.info( + `filterCluster: backend=${backend}, resolution=${voxelResolution}, opacityCutoff=${opacityCutoff}, ` + + `minContribution=${minContribution}, grid=${nx}x${ny}x${nz}` + + (backend === 'cpu' + ? `, cpuWorkerCount=${cpuOptions.workerCount && cpuOptions.workerCount > 0 ? cpuOptions.workerCount : 'auto'}` + : ''), + ); + let buffer: BlockMaskBuffer; + if (backend === 'gpu') { + try { + buffer = await gpuVoxelize( + xCol, + yCol, + zCol, + sxCol, + syCol, + szCol, + qxCol, + qyCol, + qzCol, + qwCol, + aCol, + extents, + gridBounds, + voxelResolution, + opacityCutoff, + ); + } catch (e) { + if (e instanceof Error) { + logger.error(`filterCluster GPU backend failed: ${e.message}`); + if (e.stack) { + logger.error(`filterCluster GPU stack: ${e.stack}`); + } + } else { + logger.error(`filterCluster GPU backend failed: ${String(e)}`); + } + logger.error('filterCluster GPU backend failed, fallback to CPU.'); + buffer = await cpuVoxelize( + xCol, + yCol, + zCol, + sxCol, + syCol, + szCol, + qxCol, + qyCol, + qzCol, + qwCol, + aCol, + extents, + gridBounds, + voxelResolution, + opacityCutoff, + cpuOptions, + ); + } + } else { + buffer = await cpuVoxelize( + xCol, + yCol, + zCol, + sxCol, + syCol, + szCol, + qxCol, + qyCol, + qzCol, + qwCol, + aCol, + extents, + gridBounds, + voxelResolution, + opacityCutoff, + cpuOptions, + ); + } + if (buffer.count === 0) { + logger.warn('filterCluster: no occupied voxels, returning empty data'); + return cloneRows(data, []); + } + const occupied = SparseVoxelGrid.fromBuffer(buffer, nx, ny, nz); + const blocked = occupied.cropToInverted(0, 0, 0, occupied.nbx, occupied.nby, occupied.nbz); + const seedIx = Math.max(0, Math.min(Math.floor((seed.x - gridBounds.min.x) / voxelResolution), nx - 1)); + const seedIy = Math.max(0, Math.min(Math.floor((seed.y - gridBounds.min.y) / voxelResolution), ny - 1)); + const seedIz = Math.max(0, Math.min(Math.floor((seed.z - gridBounds.min.z) / voxelResolution), nz - 1)); + const seedCluster = floodClusterFromSeed(blocked, nx, ny, nz, seedIx, seedIy, seedIz); + if (!seedCluster) { + logger.warn('filterCluster: no occupied voxel near seed, returning empty data'); + return cloneRows(data, []); + } + + const blockGrid: BlockGridParams = { + gridMinX: gridBounds.min.x, + gridMinY: gridBounds.min.y, + gridMinZ: gridBounds.min.z, + blockSize, + voxelResolution, + numBlocksX: nbx, + numBlocksY: nby, + numBlocksZ: nbz, + strideY: nbx, + strideZ: nbx * nby, + }; + const largeThreshold = 2 * voxelResolution; + const minOccupancyRatio = 0.1; + const invVoxel = 1 / voxelResolution; + + function selectGaussiansForCluster( + clusterBuffer: BlockMaskBuffer, + keepCountToExceed?: number, + ): GaussianSelectionResult { + const lookup = buildBlockLookup(clusterBuffer); + const selectedIndices: number[] = []; + for (let i = 0; i < data.counts; i++) { + const remaining = data.counts - i; + if (keepCountToExceed !== undefined && selectedIndices.length + remaining <= keepCountToExceed) { + return { + selectedIndices, + aborted: true, + }; + } + if (isCenterInOccupiedVoxel(xCol[i], yCol[i], zCol[i], blockGrid, lookup)) { + selectedIndices.push(i); + continue; + } + const ex = extents[i * 3]; + const ey = extents[i * 3 + 1]; + const ez = extents[i * 3 + 2]; + let minHits = 1; + if (Math.max(ex, ey, ez) * 2 > largeThreshold) { + const aabbVoxels = 2 * ex * invVoxel * (2 * ey * invVoxel) * (2 * ez * invVoxel); + minHits = Math.max(1, Math.ceil(aabbVoxels * minOccupancyRatio)); + } + if (gaussianContributesToVoxels(i, data, extents, blockGrid, lookup, minContribution, minHits)) { + selectedIndices.push(i); + } + } + return { + selectedIndices, + aborted: false, + }; + } + + const seedClusterBuffer = seedCluster.grid.toBuffer(0, 0, 0, nbx, nby, nbz); + if (seedClusterBuffer.count === buffer.count) { + const occupiedVoxelCount = countOccupiedVoxels(buffer); + if (seedCluster.voxelCount === occupiedVoxelCount) { + logger.info('filterCluster: all occupied voxels are connected, no filtering needed'); + return data; + } + logger.info( + `filterCluster: all occupied blocks reached but voxel count differs ` + + `(cluster=${seedCluster.voxelCount}, occupied=${occupiedVoxelCount}); continuing filtering`, + ); + } + + let chosenIndices = selectGaussiansForCluster(seedClusterBuffer).selectedIndices; + let chosenSource = 'seed'; + let chosenKeepRatio = chosenIndices.length / data.counts; + + if (hasExplicitSeed && chosenKeepRatio < FALLBACK_MIN_GAUSSIAN_RATIO) { + logger.warn( + `filterCluster: explicit seed cluster keeps ${chosenIndices.length}/${data.counts} gaussians ` + + `(${(chosenKeepRatio * 100).toFixed(1)}%); automatic fallback is disabled for explicit seeds`, + ); + } + + if (!hasExplicitSeed && chosenKeepRatio < FALLBACK_MIN_GAUSSIAN_RATIO) { + logger.warn( + `filterCluster: default seed cluster keeps ${chosenIndices.length}/${data.counts} gaussians ` + + `(${(chosenKeepRatio * 100).toFixed(1)}%), ` + + `below the fallback threshold (${(FALLBACK_MIN_GAUSSIAN_RATIO * 100).toFixed(1)}%); ` + + 'falling back to connected-component search', + ); + const { candidates, componentCount } = findLargestVoxelClusterCandidates( + buffer, + blocked, + nx, + ny, + nz, + FALLBACK_CANDIDATE_LIMIT, + seedCluster.grid, + 1, + ); + logger.info( + `filterCluster fallback: components=${componentCount}, candidates=${candidates.length}, ` + + `candidateLimit=${FALLBACK_CANDIDATE_LIMIT}`, + ); + for (let i = 0; i < candidates.length; i++) { + const candidate = candidates[i]; + const candidateClusterBuffer = candidate.grid.toBuffer(0, 0, 0, nbx, nby, nbz); + const candidateSelection = selectGaussiansForCluster(candidateClusterBuffer, chosenIndices.length); + if (candidateSelection.aborted) { + continue; + } + const candidateKeepRatio = candidateSelection.selectedIndices.length / data.counts; + if (candidateSelection.selectedIndices.length > chosenIndices.length) { + chosenIndices = candidateSelection.selectedIndices; + chosenKeepRatio = candidateKeepRatio; + chosenSource = `fallback candidate #${i + 1}`; + } + } + for (const candidate of candidates) { + candidate.grid.releaseStorage(); + } + } + + logger.info( + `filterCluster: kept ${chosenIndices.length} / ${data.counts} gaussians via ${chosenSource} ` + + `(${(chosenKeepRatio * 100).toFixed(1)}%)`, + ); + return chosenIndices.length === data.counts ? data : cloneRows(data, chosenIndices); +} diff --git a/packages/splat-transform/src/utils/voxel/gpuDilation.ts b/packages/splat-transform/src/utils/voxel/gpuDilation.ts new file mode 100644 index 0000000..5bc9fae --- /dev/null +++ b/packages/splat-transform/src/utils/voxel/gpuDilation.ts @@ -0,0 +1,938 @@ +import { getOrCreateDevice } from '../webgpu.js'; +import { BLOCK_EMPTY, BLOCK_MIXED, BLOCK_SOLID, SparseVoxelGrid, readBlockType } from './common.js'; + +const GPU_BUFFER_USAGE_STORAGE = 128; +const GPU_BUFFER_USAGE_COPY_DST = 8; +const GPU_BUFFER_USAGE_COPY_SRC = 4; +const GPU_BUFFER_USAGE_UNIFORM = 64; +const GPU_BUFFER_USAGE_MAP_READ = 1; +const GPU_MAP_MODE_READ = 1; + +const CHUNK_INNER = 512; +const SOLID_WORD = 0x55555555 >>> 0; + +function extractWgsl() { + return /* wgsl */ ` +struct ExtractUniforms { + minBx: i32, + minBy: i32, + minBz: i32, + outerBx: u32, + outerBy: u32, + outerBz: u32, + numXWords: u32, + srcNbx: u32, + srcNby: u32, + srcNbz: u32, + srcBStride: u32, + srcCapMinusOne: u32 +} + +@group(0) @binding(0) var u: ExtractUniforms; +@group(0) @binding(1) var srcTypes: array; +@group(0) @binding(2) var srcKeys: array; +@group(0) @binding(3) var srcLo: array; +@group(0) @binding(4) var srcHi: array; +@group(0) @binding(5) var dstDense: array>; + +@compute @workgroup_size(8, 4, 8) +fn main(@builtin(global_invocation_id) gid: vec3u) { + if (gid.x >= u.outerBx || gid.y >= u.outerBy || gid.z >= u.outerBz) { return; } + + let chunkBx = i32(gid.x); + let chunkBy = i32(gid.y); + let chunkBz = i32(gid.z); + let globalBx = u.minBx + chunkBx; + let globalBy = u.minBy + chunkBy; + let globalBz = u.minBz + chunkBz; + if (globalBx < 0 || globalBy < 0 || globalBz < 0) { return; } + if (globalBx >= i32(u.srcNbx) || globalBy >= i32(u.srcNby) || globalBz >= i32(u.srcNbz)) { return; } + + let blockIdx = u32(globalBx) + u32(globalBy) * u.srcNbx + u32(globalBz) * u.srcBStride; + let typeWord = srcTypes[blockIdx >> 4u]; + let bt = (typeWord >> ((blockIdx & 15u) * 2u)) & 3u; + if (bt == 0u) { return; } + + var lo: u32; + var hi: u32; + if (bt == 1u) { + lo = 0xFFFFFFFFu; + hi = 0xFFFFFFFFu; + } else { + var i = (blockIdx * 0x9E3779B9u) & u.srcCapMinusOne; + loop { + let k = srcKeys[i]; + if (k == blockIdx) { + lo = srcLo[i]; + hi = srcHi[i]; + break; + } + if (k == 0xFFFFFFFFu) { return; } + i = (i + 1u) & u.srcCapMinusOne; + } + } + + let dx0 = u32(chunkBx) * 4u; + let wordOffsetX = dx0 / 32u; + let bitShiftX = dx0 & 31u; + let outerNy = u.outerBy * 4u; + let planeWords = u.numXWords * outerNy; + + for (var lz = 0u; lz < 4u; lz = lz + 1u) { + let dz = u32(chunkBz) * 4u + lz; + let zBitBase = (lz & 1u) * 16u; + let word = select(lo, hi, lz >= 2u); + for (var ly = 0u; ly < 4u; ly = ly + 1u) { + let dy = u32(chunkBy) * 4u + ly; + let bitBase = zBitBase + ly * 4u; + let pattern = (word >> bitBase) & 0xFu; + if (pattern == 0u) { continue; } + let wordIdx = wordOffsetX + dy * u.numXWords + dz * planeWords; + atomicOr(&dstDense[wordIdx], pattern << bitShiftX); + } + } +} +`; +} + +function compactWgsl() { + return /* wgsl */ ` +struct CompactUniforms { + haloBx: u32, + haloBy: u32, + haloBz: u32, + numXWords: u32, + innerBx: u32, + innerBy: u32, + innerBz: u32, + outerBy: u32 +} + +@group(0) @binding(0) var u: CompactUniforms; +@group(0) @binding(1) var dilatedDense: array; +@group(0) @binding(2) var typesOut: array>; +@group(0) @binding(3) var masksOut: array; + +@compute @workgroup_size(8, 4, 8) +fn main(@builtin(global_invocation_id) gid: vec3u) { + if (gid.x >= u.innerBx || gid.y >= u.innerBy || gid.z >= u.innerBz) { return; } + + let innerBlockIdx = gid.x + gid.y * u.innerBx + gid.z * u.innerBx * u.innerBy; + let outerBx = gid.x + u.haloBx; + let outerBy = gid.y + u.haloBy; + let outerBz = gid.z + u.haloBz; + let dx0 = outerBx * 4u; + let wordOffsetX = dx0 / 32u; + let bitShiftX = dx0 & 31u; + let outerNy = u.outerBy * 4u; + let planeWords = u.numXWords * outerNy; + + var lo = 0u; + var hi = 0u; + for (var lz = 0u; lz < 4u; lz = lz + 1u) { + let dz = outerBz * 4u + lz; + let zBitBase = (lz & 1u) * 16u; + let inHi = lz >= 2u; + let planeBase = dz * planeWords; + for (var ly = 0u; ly < 4u; ly = ly + 1u) { + let dy = outerBy * 4u + ly; + let bitBase = zBitBase + ly * 4u; + let wordIdx = wordOffsetX + dy * u.numXWords + planeBase; + let pattern = (dilatedDense[wordIdx] >> bitShiftX) & 0xFu; + let bits = pattern << bitBase; + if (inHi) { hi = hi | bits; } else { lo = lo | bits; } + } + } + + masksOut[innerBlockIdx * 2u] = lo; + masksOut[innerBlockIdx * 2u + 1u] = hi; + + var bt = 0u; + if (lo != 0u || hi != 0u) { + if (lo == 0xFFFFFFFFu && hi == 0xFFFFFFFFu) { bt = 1u; } else { bt = 2u; } + } + let typeWordIdx = innerBlockIdx >> 4u; + let typeBitShift = (innerBlockIdx & 15u) * 2u; + atomicOr(&typesOut[typeWordIdx], bt << typeBitShift); +} +`; +} + +function dilateXWgsl() { + return /* wgsl */ ` +struct DilateXUniforms { + numXWords: u32, + ny: u32, + nz: u32, + halfExtent: u32 +} + +@group(0) @binding(0) var u: DilateXUniforms; +@group(0) @binding(1) var src: array; +@group(0) @binding(2) var dst: array; + +fn readWord(rowOffset: u32, word: i32) -> u32 { + if (word < 0 || word >= i32(u.numXWords)) { return 0u; } + return src[rowOffset + u32(word)]; +} + +@compute @workgroup_size(8, 4, 8) +fn main(@builtin(global_invocation_id) gid: vec3u) { + if (gid.x >= u.numXWords || gid.y >= u.ny || gid.z >= u.nz) { return; } + + let xWord = gid.x; + let y = gid.y; + let z = gid.z; + let rowStride = u.numXWords; + let planeStride = rowStride * u.ny; + let rowOffset = y * rowStride + z * planeStride; + var output = src[rowOffset + xWord]; + let rowBits = u.numXWords * 32u; + let r = min(u.halfExtent, rowBits); + for (var d = 1u; d <= r; d = d + 1u) { + let wordOffset = i32(d >> 5u); + let bitShift = d & 31u; + let baseWord = i32(xWord); + var shiftedPos = readWord(rowOffset, baseWord + wordOffset); + if (bitShift != 0u) { + shiftedPos = (shiftedPos >> bitShift) | (readWord(rowOffset, baseWord + wordOffset + 1) << (32u - bitShift)); + } + var shiftedNeg = readWord(rowOffset, baseWord - wordOffset); + if (bitShift != 0u) { + shiftedNeg = (shiftedNeg << bitShift) | (readWord(rowOffset, baseWord - wordOffset - 1) >> (32u - bitShift)); + } + output = output | shiftedPos | shiftedNeg; + if (output == 0xFFFFFFFFu) { break; } + } + dst[rowOffset + xWord] = output; +} +`; +} + +function dilateYZWgsl() { + return /* wgsl */ ` +struct DilateYZUniforms { + numXWords: u32, + ny: u32, + nz: u32, + halfExtent: u32, + stride: u32, + axisLen: u32 +} + +@group(0) @binding(0) var u: DilateYZUniforms; +@group(0) @binding(1) var src: array; +@group(0) @binding(2) var dst: array; + +@compute @workgroup_size(8, 4, 8) +fn main(@builtin(global_invocation_id) gid: vec3u) { + if (gid.x >= u.numXWords || gid.y >= u.ny || gid.z >= u.nz) { return; } + + let xWord = gid.x; + let y = gid.y; + let z = gid.z; + let rowStride = u.numXWords; + let planeStride = rowStride * u.ny; + let outIdx = i32(xWord) + i32(y) * i32(rowStride) + i32(z) * i32(planeStride); + let pos = select(z, y, u.stride == rowStride); + let r = i32(u.halfExtent); + let lo = max(0, i32(pos) - r); + let hi = min(i32(u.axisLen) - 1, i32(pos) + r); + let baseIdx = outIdx - i32(pos) * i32(u.stride); + var output = 0u; + for (var p = lo; p <= hi; p = p + 1) { + output = output | src[baseIdx + p * i32(u.stride)]; + if (output == 0xFFFFFFFFu) { break; } + } + dst[outIdx] = output; +} +`; +} + +interface BufferSlot { + bufferA: GPUBuffer; + bufferB: GPUBuffer; + readTypesBuffer: GPUBuffer; + readMasksBuffer: GPUBuffer; + typesOutBuffer: GPUBuffer; + masksOutBuffer: GPUBuffer; + capacity: number; + typesOutCapacity: number; + masksOutCapacity: number; +} + +interface Inflight { + typesPromise: Promise; + masksPromise: Promise; + cx: number; + cy: number; + cz: number; + innerNx: number; + innerNy: number; + innerNz: number; +} + +function makeBuffer(device: GPUDevice, size: number, usage: number): GPUBuffer { + return device.createBuffer({ size: Math.max(4, size), usage }); +} + +function writeUniform(device: GPUDevice, values: Uint32Array): GPUBuffer { + const buffer = makeBuffer(device, 256, GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST); + device.queue.writeBuffer(buffer, 0, values.buffer as ArrayBuffer, values.byteOffset, values.byteLength); + return buffer; +} + +function createStoragePipeline(device: GPUDevice, code: string): GPUComputePipeline { + return device.createComputePipeline({ + layout: 'auto', + compute: { module: device.createShaderModule({ code }), entryPoint: 'main' }, + }); +} + +function blockAlignedExtent(halfExtent: number): number { + return halfExtent === 0 ? 0 : Math.ceil(halfExtent / 4) * 4; +} + +function chunkIsEmpty( + src: SparseVoxelGrid, + ox: number, + oy: number, + oz: number, + cx: number, + cy: number, + cz: number, +): boolean { + const minBx = Math.max(0, Math.floor(ox / 4)); + const minBy = Math.max(0, Math.floor(oy / 4)); + const minBz = Math.max(0, Math.floor(oz / 4)); + const maxBx = Math.min(src.nbx, Math.ceil((ox + cx) / 4)); + const maxBy = Math.min(src.nby, Math.ceil((oy + cy) / 4)); + const maxBz = Math.min(src.nbz, Math.ceil((oz + cz) / 4)); + if (maxBx <= minBx || maxBy <= minBy || maxBz <= minBz) { + return true; + } + + for (let bz = minBz; bz < maxBz; bz++) { + for (let by = minBy; by < maxBy; by++) { + for (let bx = minBx; bx < maxBx; bx++) { + const blockIdx = bx + by * src.nbx + bz * src.bStride; + if (readBlockType(src.types, blockIdx) !== BLOCK_EMPTY) { + return false; + } + } + } + } + return true; +} + +function chunkIsSaturated( + src: SparseVoxelGrid, + ox: number, + oy: number, + oz: number, + cx: number, + cy: number, + cz: number, +): boolean { + if (ox < 0 || oy < 0 || oz < 0) { + return false; + } + if (ox + cx > src.nx || oy + cy > src.ny || oz + cz > src.nz) { + return false; + } + + const minBx = ox >> 2; + const minBy = oy >> 2; + const minBz = oz >> 2; + const maxBx = (ox + cx + 3) >> 2; + const maxBy = (oy + cy + 3) >> 2; + const maxBz = (oz + cz + 3) >> 2; + for (let bz = minBz; bz < maxBz; bz++) { + for (let by = minBy; by < maxBy; by++) { + for (let bx = minBx; bx < maxBx; bx++) { + const blockIdx = bx + by * src.nbx + bz * src.bStride; + if (readBlockType(src.types, blockIdx) !== BLOCK_SOLID) { + return false; + } + } + } + } + return true; +} + +function insertSaturatedInner( + dst: SparseVoxelGrid, + innerOx: number, + innerOy: number, + innerOz: number, + innerCx: number, + innerCy: number, + innerCz: number, +): void { + const minBx = Math.max(0, innerOx >> 2); + const minBy = Math.max(0, innerOy >> 2); + const minBz = Math.max(0, innerOz >> 2); + const maxBx = Math.min(dst.nbx, (innerOx + innerCx + 3) >> 2); + const maxBy = Math.min(dst.nby, (innerOy + innerCy + 3) >> 2); + const maxBz = Math.min(dst.nbz, (innerOz + innerCz + 3) >> 2); + for (let bz = minBz; bz < maxBz; bz++) { + for (let by = minBy; by < maxBy; by++) { + const rowBase = by * dst.nbx + bz * dst.bStride; + let blockIdx = rowBase + minBx; + const endIdx = rowBase + maxBx; + while (blockIdx < endIdx) { + const w = blockIdx >>> 4; + const shift = (blockIdx & 15) << 1; + const remainingInWord = 16 - (blockIdx & 15); + const remainingInRow = endIdx - blockIdx; + const blocksToWrite = Math.min(remainingInWord, remainingInRow); + if (blocksToWrite === 16) { + dst.types[w] = SOLID_WORD; + } else { + const bits = blocksToWrite << 1; + const mask = (((1 << bits) - 1) >>> 0) << shift; + dst.types[w] = ((dst.types[w] & ~mask) | (SOLID_WORD & mask)) >>> 0; + } + blockIdx += blocksToWrite; + } + } + } +} + +function applyChunkToDst( + dst: SparseVoxelGrid, + typesOut: Uint32Array, + masksOut: Uint32Array, + cx: number, + cy: number, + cz: number, + innerNx: number, + innerNy: number, + innerNz: number, +): void { + const innerBx = innerNx >> 2; + const innerBy = innerNy >> 2; + const innerBz = innerNz >> 2; + const baseBx = cx >> 2; + const baseBy = cy >> 2; + const baseBz = cz >> 2; + let innerIdx = 0; + for (let bz = 0; bz < innerBz; bz++) { + const globalBz = baseBz + bz; + for (let by = 0; by < innerBy; by++) { + const globalBy = baseBy + by; + const baseGlobalIdx = baseBx + globalBy * dst.nbx + globalBz * dst.bStride; + for (let bx = 0; bx < innerBx; bx++, innerIdx++) { + const wordIdx = innerIdx >>> 4; + const bitShift = (innerIdx & 15) << 1; + const bt = (typesOut[wordIdx] >>> bitShift) & 3; + if (bt === BLOCK_EMPTY) { + continue; + } + const globalBlockIdx = baseGlobalIdx + bx; + const w = globalBlockIdx >>> 4; + const shift = (globalBlockIdx & 15) << 1; + dst.types[w] |= bt << shift; + if (bt === BLOCK_MIXED) { + const m2 = innerIdx * 2; + dst.masks.set(globalBlockIdx, masksOut[m2], masksOut[m2 + 1]); + } + } + } + } +} + +class GpuDilation { + private readonly device: GPUDevice; + private readonly extractPipeline: GPUComputePipeline; + private readonly compactPipeline: GPUComputePipeline; + private readonly dilateXPipeline: GPUComputePipeline; + private readonly dilateYZPipeline: GPUComputePipeline; + private readonly slots: BufferSlot[] = []; + private srcTypesBuffer: GPUBuffer | undefined; + private srcKeysBuffer: GPUBuffer | undefined; + private srcLoBuffer: GPUBuffer | undefined; + private srcHiBuffer: GPUBuffer | undefined; + private srcMeta = { nbx: 0, nby: 0, nbz: 0, bStride: 0, capMinusOne: 0 }; + + static readonly NUM_SLOTS = 2; + + constructor(device: GPUDevice) { + this.device = device; + this.extractPipeline = createStoragePipeline(device, extractWgsl()); + this.compactPipeline = createStoragePipeline(device, compactWgsl()); + this.dilateXPipeline = createStoragePipeline(device, dilateXWgsl()); + this.dilateYZPipeline = createStoragePipeline(device, dilateYZWgsl()); + for (let i = 0; i < GpuDilation.NUM_SLOTS; i++) { + const capacity = 1024 * 1024 * 4; + const typesOutCapacity = 64 * 1024; + const masksOutCapacity = 1024 * 1024; + this.slots.push({ + bufferA: makeBuffer( + device, + capacity, + GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_COPY_SRC, + ), + bufferB: makeBuffer( + device, + capacity, + GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_COPY_SRC, + ), + readTypesBuffer: makeBuffer( + device, + typesOutCapacity, + GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_MAP_READ, + ), + readMasksBuffer: makeBuffer( + device, + masksOutCapacity, + GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_MAP_READ, + ), + typesOutBuffer: makeBuffer( + device, + typesOutCapacity, + GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_COPY_SRC, + ), + masksOutBuffer: makeBuffer( + device, + masksOutCapacity, + GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_COPY_SRC, + ), + capacity, + typesOutCapacity, + masksOutCapacity, + }); + } + } + + private replaceBuffer(slot: BufferSlot, key: keyof BufferSlot, size: number, usage: number): void { + (slot[key] as GPUBuffer).destroy(); + (slot as unknown as Record)[key] = makeBuffer(this.device, size, usage); + } + + private ensureSlotBuffers(slot: BufferSlot, numWords: number): void { + const neededBytes = numWords * 4; + if (neededBytes <= slot.capacity) { + return; + } + let cap = slot.capacity; + while (cap < neededBytes) { + cap *= 2; + } + this.replaceBuffer( + slot, + 'bufferA', + cap, + GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_COPY_SRC, + ); + this.replaceBuffer( + slot, + 'bufferB', + cap, + GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_COPY_SRC, + ); + slot.capacity = cap; + } + + private ensureSlotOutputBuffers(slot: BufferSlot, innerBlocks: number): void { + const typesBytes = ((innerBlocks + 15) >>> 4) * 4; + if (slot.typesOutCapacity < typesBytes) { + this.replaceBuffer( + slot, + 'typesOutBuffer', + typesBytes, + GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_COPY_SRC, + ); + this.replaceBuffer( + slot, + 'readTypesBuffer', + typesBytes, + GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_MAP_READ, + ); + slot.typesOutCapacity = typesBytes; + } + const masksBytes = innerBlocks * 8; + if (slot.masksOutCapacity < masksBytes) { + this.replaceBuffer( + slot, + 'masksOutBuffer', + masksBytes, + GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_COPY_SRC, + ); + this.replaceBuffer( + slot, + 'readMasksBuffer', + masksBytes, + GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_MAP_READ, + ); + slot.masksOutCapacity = masksBytes; + } + } + + uploadSrc(src: SparseVoxelGrid): void { + this.releaseSrc(); + this.srcTypesBuffer = makeBuffer( + this.device, + src.types.byteLength, + GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST, + ); + this.device.queue.writeBuffer( + this.srcTypesBuffer, + 0, + src.types.buffer as ArrayBuffer, + src.types.byteOffset, + src.types.byteLength, + ); + const keysU32 = new Uint32Array(src.masks.keys.buffer, src.masks.keys.byteOffset, src.masks.keys.length); + this.srcKeysBuffer = makeBuffer( + this.device, + keysU32.byteLength, + GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST, + ); + this.srcLoBuffer = makeBuffer( + this.device, + src.masks.lo.byteLength, + GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST, + ); + this.srcHiBuffer = makeBuffer( + this.device, + src.masks.hi.byteLength, + GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST, + ); + this.device.queue.writeBuffer( + this.srcKeysBuffer, + 0, + keysU32.buffer as ArrayBuffer, + keysU32.byteOffset, + keysU32.byteLength, + ); + this.device.queue.writeBuffer( + this.srcLoBuffer, + 0, + src.masks.lo.buffer as ArrayBuffer, + src.masks.lo.byteOffset, + src.masks.lo.byteLength, + ); + this.device.queue.writeBuffer( + this.srcHiBuffer, + 0, + src.masks.hi.buffer as ArrayBuffer, + src.masks.hi.byteOffset, + src.masks.hi.byteLength, + ); + this.srcMeta = { + nbx: src.nbx, + nby: src.nby, + nbz: src.nbz, + bStride: src.bStride, + capMinusOne: src.masks.keys.length - 1, + }; + } + + releaseSrc(): void { + this.srcTypesBuffer?.destroy(); + this.srcKeysBuffer?.destroy(); + this.srcLoBuffer?.destroy(); + this.srcHiBuffer?.destroy(); + this.srcTypesBuffer = undefined; + this.srcKeysBuffer = undefined; + this.srcLoBuffer = undefined; + this.srcHiBuffer = undefined; + } + + submitChunkSparse( + slotIdx: number, + minBx: number, + minBy: number, + minBz: number, + outerBx: number, + outerBy: number, + outerBz: number, + haloBx: number, + haloBy: number, + haloBz: number, + innerBx: number, + innerBy: number, + innerBz: number, + halfExtentXZ: number, + halfExtentY: number, + ): { types: Promise; masks: Promise } { + if (!this.srcTypesBuffer || !this.srcKeysBuffer || !this.srcLoBuffer || !this.srcHiBuffer) { + throw new Error('GpuDilation: must call uploadSrc() before submitChunkSparse()'); + } + const slot = this.slots[slotIdx]; + const outerNx = outerBx * 4; + const outerNy = outerBy * 4; + const outerNz = outerBz * 4; + const numXWords = (outerNx + 31) >>> 5; + const numWords = numXWords * outerNy * outerNz; + const innerBlocks = innerBx * innerBy * innerBz; + const typesOutWords = (innerBlocks + 15) >>> 4; + this.ensureSlotBuffers(slot, numWords); + this.ensureSlotOutputBuffers(slot, innerBlocks); + const uniformBuffers: GPUBuffer[] = []; + const device = this.device; + function makeUniform(values: Uint32Array): GPUBuffer { + const buffer = writeUniform(device, values); + uniformBuffers.push(buffer); + return buffer; + } + + { + const encoder = this.device.createCommandEncoder(); + encoder.clearBuffer(slot.bufferA, 0, numWords * 4); + const uniforms = new Uint32Array([ + minBx >>> 0, + minBy >>> 0, + minBz >>> 0, + outerBx, + outerBy, + outerBz, + numXWords, + this.srcMeta.nbx, + this.srcMeta.nby, + this.srcMeta.nbz, + this.srcMeta.bStride, + this.srcMeta.capMinusOne, + ]); + const uniformBuffer = makeUniform(uniforms); + const bindGroup = this.device.createBindGroup({ + layout: this.extractPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: uniformBuffer } }, + { binding: 1, resource: { buffer: this.srcTypesBuffer } }, + { binding: 2, resource: { buffer: this.srcKeysBuffer } }, + { binding: 3, resource: { buffer: this.srcLoBuffer } }, + { binding: 4, resource: { buffer: this.srcHiBuffer } }, + { binding: 5, resource: { buffer: slot.bufferA } }, + ], + }); + const pass = encoder.beginComputePass(); + pass.setPipeline(this.extractPipeline); + pass.setBindGroup(0, bindGroup); + pass.dispatchWorkgroups(Math.ceil(outerBx / 8), Math.ceil(outerBy / 4), Math.ceil(outerBz / 8)); + pass.end(); + this.device.queue.submit([encoder.finish()]); + } + + { + const encoder = this.device.createCommandEncoder(); + function dispatch( + pipeline: GPUComputePipeline, + src: GPUBuffer, + dst: GPUBuffer, + uniforms: Uint32Array, + wgX: number, + wgY: number, + wgZ: number, + ): void { + const uniformBuffer = makeUniform(uniforms); + const bindGroup = device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: uniformBuffer } }, + { binding: 1, resource: { buffer: src } }, + { binding: 2, resource: { buffer: dst } }, + ], + }); + const pass = encoder.beginComputePass(); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.dispatchWorkgroups(wgX, wgY, wgZ); + pass.end(); + } + dispatch( + this.dilateXPipeline, + slot.bufferA, + slot.bufferB, + new Uint32Array([numXWords, outerNy, outerNz, halfExtentXZ]), + Math.ceil(numXWords / 8), + Math.ceil(outerNy / 4), + Math.ceil(outerNz / 8), + ); + dispatch( + this.dilateYZPipeline, + slot.bufferB, + slot.bufferA, + new Uint32Array([numXWords, outerNy, outerNz, halfExtentXZ, numXWords * outerNy, outerNz]), + Math.ceil(numXWords / 8), + Math.ceil(outerNy / 4), + Math.ceil(outerNz / 8), + ); + dispatch( + this.dilateYZPipeline, + slot.bufferA, + slot.bufferB, + new Uint32Array([numXWords, outerNy, outerNz, halfExtentY, numXWords, outerNy]), + Math.ceil(numXWords / 8), + Math.ceil(outerNy / 4), + Math.ceil(outerNz / 8), + ); + encoder.clearBuffer(slot.typesOutBuffer, 0, typesOutWords * 4); + const compactUniformBuffer = makeUniform( + new Uint32Array([haloBx, haloBy, haloBz, numXWords, innerBx, innerBy, innerBz, outerBy]), + ); + const compactBindGroup = this.device.createBindGroup({ + layout: this.compactPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: compactUniformBuffer } }, + { binding: 1, resource: { buffer: slot.bufferB } }, + { binding: 2, resource: { buffer: slot.typesOutBuffer } }, + { binding: 3, resource: { buffer: slot.masksOutBuffer } }, + ], + }); + const pass = encoder.beginComputePass(); + pass.setPipeline(this.compactPipeline); + pass.setBindGroup(0, compactBindGroup); + pass.dispatchWorkgroups(Math.ceil(innerBx / 8), Math.ceil(innerBy / 4), Math.ceil(innerBz / 8)); + pass.end(); + encoder.copyBufferToBuffer(slot.typesOutBuffer, 0, slot.readTypesBuffer, 0, typesOutWords * 4); + encoder.copyBufferToBuffer(slot.masksOutBuffer, 0, slot.readMasksBuffer, 0, innerBlocks * 8); + this.device.queue.submit([encoder.finish()]); + } + + const typesPromise = (async () => { + await slot.readTypesBuffer.mapAsync(GPU_MAP_MODE_READ, 0, typesOutWords * 4); + const mapped = new Uint32Array(slot.readTypesBuffer.getMappedRange(0, typesOutWords * 4)); + const out = new Uint32Array(typesOutWords); + out.set(mapped); + slot.readTypesBuffer.unmap(); + return out; + })(); + const masksPromise = (async () => { + await slot.readMasksBuffer.mapAsync(GPU_MAP_MODE_READ, 0, innerBlocks * 8); + const mapped = new Uint32Array(slot.readMasksBuffer.getMappedRange(0, innerBlocks * 8)); + const out = new Uint32Array(innerBlocks * 2); + out.set(mapped); + slot.readMasksBuffer.unmap(); + return out; + })(); + void Promise.all([typesPromise, masksPromise]).then(() => { + for (const buffer of uniformBuffers) { + buffer.destroy(); + } + }); + return { types: typesPromise, masks: masksPromise }; + } + + destroy(): void { + this.releaseSrc(); + for (const slot of this.slots) { + slot.bufferA.destroy(); + slot.bufferB.destroy(); + slot.readTypesBuffer.destroy(); + slot.readMasksBuffer.destroy(); + slot.typesOutBuffer.destroy(); + slot.masksOutBuffer.destroy(); + } + } +} + +export async function gpuDilate3( + src: SparseVoxelGrid, + halfExtentXZ: number, + halfExtentY: number, +): Promise { + if (halfExtentXZ === 0 && halfExtentY === 0) { + return src.clone(); + } + if (!Number.isInteger(halfExtentXZ) || halfExtentXZ < 0) { + throw new Error(`gpuDilate3: halfExtentXZ=${halfExtentXZ} must be a non-negative integer`); + } + if (!Number.isInteger(halfExtentY) || halfExtentY < 0) { + throw new Error(`gpuDilate3: halfExtentY=${halfExtentY} must be a non-negative integer`); + } + + const device = await getOrCreateDevice(); + const gpu = new GpuDilation(device); + const dst = new SparseVoxelGrid(src.nx, src.ny, src.nz); + const haloX = blockAlignedExtent(halfExtentXZ); + const haloY = blockAlignedExtent(halfExtentY); + const haloZ = haloX; + const haloBx = haloX / 4; + const haloBy = haloY / 4; + const haloBz = haloZ / 4; + const innerStep = CHUNK_INNER & ~3; + let currentSlot = 0; + let inflight: Inflight | undefined; + async function drainInflight(): Promise { + if (!inflight) { + return; + } + const f = inflight; + inflight = undefined; + const [typesOut, masksOut] = await Promise.all([f.typesPromise, f.masksPromise]); + applyChunkToDst(dst, typesOut, masksOut, f.cx, f.cy, f.cz, f.innerNx, f.innerNy, f.innerNz); + } + + gpu.uploadSrc(src); + try { + for (let cz = 0; cz < src.nz; cz += innerStep) { + for (let cy = 0; cy < src.ny; cy += innerStep) { + for (let cx = 0; cx < src.nx; cx += innerStep) { + const innerNx = Math.min(innerStep, src.nx - cx); + const innerNy = Math.min(innerStep, src.ny - cy); + const innerNz = Math.min(innerStep, src.nz - cz); + const ox = cx - haloX; + const oy = cy - haloY; + const oz = cz - haloZ; + const outerNx = innerNx + 2 * haloX; + const outerNy = innerNy + 2 * haloY; + const outerNz = innerNz + 2 * haloZ; + if (chunkIsEmpty(src, ox, oy, oz, outerNx, outerNy, outerNz)) { + continue; + } + if (chunkIsSaturated(src, ox, oy, oz, outerNx, outerNy, outerNz)) { + insertSaturatedInner(dst, cx, cy, cz, innerNx, innerNy, innerNz); + continue; + } + + const innerBx = innerNx >> 2; + const innerBy = innerNy >> 2; + const innerBz = innerNz >> 2; + const outerBx = outerNx >> 2; + const outerBy = outerNy >> 2; + const outerBz = outerNz >> 2; + const minBx = Math.floor(ox / 4); + const minBy = Math.floor(oy / 4); + const minBz = Math.floor(oz / 4); + const { types, masks } = gpu.submitChunkSparse( + currentSlot, + minBx, + minBy, + minBz, + outerBx, + outerBy, + outerBz, + haloBx, + haloBy, + haloBz, + innerBx, + innerBy, + innerBz, + halfExtentXZ, + halfExtentY, + ); + if (inflight) { + await drainInflight(); + } + inflight = { + typesPromise: types, + masksPromise: masks, + cx, + cy, + cz, + innerNx, + innerNy, + innerNz, + }; + currentSlot = (currentSlot + 1) % GpuDilation.NUM_SLOTS; + } + } + } + await drainInflight(); + } finally { + gpu.destroy(); + } + return dst; +} diff --git a/packages/splat-transform/src/utils/voxel/marchingCubes.ts b/packages/splat-transform/src/utils/voxel/marchingCubes.ts new file mode 100644 index 0000000..405ca8c --- /dev/null +++ b/packages/splat-transform/src/utils/voxel/marchingCubes.ts @@ -0,0 +1,1853 @@ +import { + BLOCKS_PER_WORD, + EVEN_BITS, + BLOCK_EMPTY, + BLOCK_SOLID, + type SparseVoxelGrid, + readBlockType, + type Bounds, +} from './common.js'; + +/** + * A simple triangle mesh with positions and indices. + */ +interface Mesh { + /** Vertex positions (3 floats per vertex) */ + positions: Float32Array; + + /** Triangle indices (3 indices per triangle) */ + indices: Uint32Array; +} + +/** + * Result of marching cubes surface extraction. + */ +type MarchingCubesMesh = Mesh; + +/** + * Options for marching cubes extraction. + */ +interface MarchingCubesOptions { + /** + * Pre-merge exact full-face cells on flat axis-aligned regions before + * creating the mesh. Ambiguous and bevel cases still use normal marching + * cubes, so coplanarMerge can apply the final lossless optimization. + */ + mergeFlatFaces?: boolean; +} + +// ============================================================================ +// Voxel bit helpers +// ============================================================================ + +// Bit layout within a 4x4x4 block: bitIdx = lx + ly*4 + lz*16 +// lo = bits 0-31 (lz 0-1), hi = bits 32-63 (lz 2-3) + +/** + * Test whether a voxel is occupied within a block's bitmask. + * + * @param lo - Lower 32 bits of the block mask + * @param hi - Upper 32 bits of the block mask + * @param lx - Local x coordinate (0-3) + * @param ly - Local y coordinate (0-3) + * @param lz - Local z coordinate (0-3) + * @returns True if the voxel is occupied + */ +function isVoxelSet(lo: number, hi: number, lx: number, ly: number, lz: number): boolean { + const bitIdx = lx + ly * 4 + lz * 16; + if (bitIdx < 32) { + return (lo & (1 << bitIdx)) !== 0; + } + return (hi & (1 << (bitIdx - 32))) !== 0; +} + +// ============================================================================ +// Marching Cubes +// ============================================================================ + +// Sentinel values for the per-block 3x3x3 neighbor table. +const NEIGHBOR_EMPTY = -2; +const NEIGHBOR_SOLID = -1; + +/** + * Extract a triangle mesh from a SparseVoxelGrid using marching cubes. + * + * Each voxel is treated as a cell in the marching cubes grid. Corner values + * are binary (0 = empty, 1 = occupied) with a 0.5 threshold. Vertices are + * placed at edge midpoints, producing the binary-field isosurface between + * occupied and empty samples. + * + * @param grid - Voxel grid (after filtering / nav phases) + * @param gridBounds - Grid bounds aligned to block boundaries + * @param voxelResolution - Size of each voxel in world units + * @param options - Optional extraction settings + * @returns Mesh with positions and indices + */ +function marchingCubes( + grid: SparseVoxelGrid, + gridBounds: Bounds, + voxelResolution: number, + options: MarchingCubesOptions = {}, +): MarchingCubesMesh { + const { nbx, nby, nbz, bStride, types, masks } = grid; + const totalBlocks = nbx * nby * nbz; + const mergeFlatFaces = options.mergeFlatFaces === true; + + // Vertex deduplication: edge ID -> vertex index. Open-addressed typed- + // array hash table rather than `Map` because (1) a single + // V8 Map is capped at ~2^24 entries (large carved scenes blow past this), + // and (2) per-entry overhead in Map is ~50 bytes vs 12 bytes (Float64 key + // + Uint32 value) here, which matters when the table holds tens of + // millions of vertices. + // + // Empty slots are marked with `key === -1` (real keys are non-negative). + // Hash uses Fibonacci constant on the lower 32 bits of the key. The same + // structure is used for orphan-cell deduplication, where `vVals` is unused. + let vCap = 1 << 14; + let vMask = vCap - 1; + let vSize = 0; + let vKeys = new Float64Array(vCap).fill(-1); + let vVals = new Uint32Array(vCap); + + function vGrow(): void { + const oldKeys = vKeys; + const oldVals = vVals; + const oldCap = vCap; + vCap *= 2; + vMask = vCap - 1; + vKeys = new Float64Array(vCap).fill(-1); + vVals = new Uint32Array(vCap); + for (let j = 0; j < oldCap; j++) { + const k = oldKeys[j]; + if (k === -1) { + continue; + } + let i = (Math.imul(k | 0, 0x9e3779b9) >>> 0) & vMask; + while (vKeys[i] !== -1) { + i = (i + 1) & vMask; + } + vKeys[i] = k; + vVals[i] = oldVals[j]; + } + } + + let oCap = 1 << 14; + let oMask = oCap - 1; + let oSize = 0; + let oKeys = new Float64Array(oCap).fill(-1); + + function oGrow(): void { + const oldKeys = oKeys; + const oldCap = oCap; + oCap *= 2; + oMask = oCap - 1; + oKeys = new Float64Array(oCap).fill(-1); + for (let j = 0; j < oldCap; j++) { + const k = oldKeys[j]; + if (k === -1) { + continue; + } + let i = (Math.imul(k | 0, 0x9e3779b9) >>> 0) & oMask; + while (oKeys[i] !== -1) { + i = (i + 1) & oMask; + } + oKeys[i] = k; + } + } + + // Growable typed-array buffers. Capacity doubles on demand to avoid + // the GC churn of pushing into JS number[] for huge meshes. + let posCap = 1024; + let posLen = 0; + let positions = new Float32Array(posCap); + let idxCap = 1024; + let idxLen = 0; + let indices = new Uint32Array(idxCap); + + const originX = gridBounds.min.x; + const originY = gridBounds.min.y; + const originZ = gridBounds.min.z; + + const gridNx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); + const gridNy = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); + const gridNz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); + + // Compute strides from actual grid dimensions (+3 for the -1 boundary + // extension, the far edge +1, and one extra for safety). + const strideX = gridNx + 3; + const strideXY = strideX * (gridNy + 3); + const scaledCoordStride = (Math.max(gridNx, gridNy, gridNz) + 3) * 2 + 5; + const scaledCoordOffset = 3; + + // Per-block 3x3x3 neighbor lookup table populated once per processed block. + // Index = (dx+1) + (dy+1)*3 + (dz+1)*9, dx/dy/dz in {-1, 0, 1}. + // neighborEntry: NEIGHBOR_EMPTY, NEIGHBOR_SOLID, or NEIGHBOR_MIXED (mask in neighborMasks). + const NEIGHBOR_MIXED = 0; + const neighborEntry = new Int32Array(27); + const neighborMasks = new Uint32Array(54); + + // Reused scratch for emitted edge vertex indices. + const edgeVerts = new Int32Array(12); + + // Block coordinate of the block currently being processed. Captured by + // isOccupiedLocal so it can fold the per-corner block lookup into a + // direct typed-array index instead of a hash lookup. + let bx = 0, + by = 0, + bz = 0; + + function isOccupiedLocal(cx: number, cy: number, cz: number): boolean { + if (cx < 0 || cy < 0 || cz < 0) { + return false; + } + const idx = (cx >> 2) - bx + 1 + ((cy >> 2) - by + 1) * 3 + ((cz >> 2) - bz + 1) * 9; + const entry = neighborEntry[idx]; + if (entry === NEIGHBOR_EMPTY) { + return false; + } + if (entry === NEIGHBOR_SOLID) { + return true; + } + const lo = neighborMasks[idx * 2]; + const hi = neighborMasks[idx * 2 + 1]; + return isVoxelSet(lo, hi, cx & 3, cy & 3, cz & 3); + } + + function addPosition(px: number, py: number, pz: number): number { + if (posLen + 3 > posCap) { + posCap *= 2; + const grown = new Float32Array(posCap); + grown.set(positions); + positions = grown; + } + + const idx = posLen / 3; + positions[posLen++] = px; + positions[posLen++] = py; + positions[posLen++] = pz; + return idx; + } + + function ensureIndexCapacity(additional: number): void { + if (idxLen + additional <= idxCap) { + return; + } + while (idxLen + additional > idxCap) { + idxCap *= 2; + } + const grown = new Uint32Array(idxCap); + grown.set(indices); + indices = grown; + } + + function appendTri(a: number, b: number, c: number): void { + ensureIndexCapacity(3); + indices[idxLen++] = a; + indices[idxLen++] = b; + indices[idxLen++] = c; + } + + // When flat MC face cells are merged into large rectangles, rectangle + // boundaries must still be split at any neighbouring raw-MC vertex that + // lies along the same edge. Otherwise the pre-merged mesh can contain + // T-junctions before the final coplanarMerge pass. Coordinates here use + // the exact binary-MC half-grid: voxel corners are even, MC edge + // midpoints are odd on the crossed edge axis. + const splitLinePoints = mergeFlatFaces ? new Map() : undefined; + let collectSplitPoints = mergeFlatFaces; + + function splitLineKey(varAxis: number, x2: number, y2: number, z2: number): number { + const x = x2 + scaledCoordOffset; + const y = y2 + scaledCoordOffset; + const z = z2 + scaledCoordOffset; + if (varAxis === 0) { + return (y * scaledCoordStride + z) * 3; + } + if (varAxis === 1) { + return (x * scaledCoordStride + z) * 3 + 1; + } + return (x * scaledCoordStride + y) * 3 + 2; + } + + function addSplitLinePoint(key: number, value: number): void { + if (!splitLinePoints) { + return; + } + let points = splitLinePoints.get(key); + if (!points) { + points = []; + splitLinePoints.set(key, points); + } + points.push(value); + } + + function addSplitPointForVertex(vx: number, vy: number, vz: number, axis: number): void { + if (!splitLinePoints || !collectSplitPoints) { + return; + } + const x2 = vx * 2 + (axis === 0 ? 1 : 0); + const y2 = vy * 2 + (axis === 1 ? 1 : 0); + const z2 = vz * 2 + (axis === 2 ? 1 : 0); + + addSplitLinePoint(splitLineKey(0, x2, y2, z2), x2); + addSplitLinePoint(splitLineKey(1, x2, y2, z2), y2); + addSplitLinePoint(splitLineKey(2, x2, y2, z2), z2); + } + + // Get or create a vertex at the midpoint of an edge. + // Edge is identified by the lower corner voxel coordinate and axis (0=x, 1=y, 2=z). + function getVertex(vx: number, vy: number, vz: number, axis: number): number { + // Pack (vx, vy, vz, axis) into a single key. Offset by 1 so that + // vx = -1 (from the boundary extension) maps to 0, keeping keys non-negative. + const key = (vx + 1 + (vy + 1) * strideX + (vz + 1) * strideXY) * 3 + axis; + + // Probe for either the matching slot or the next empty one. + let i = (Math.imul(key | 0, 0x9e3779b9) >>> 0) & vMask; + while (true) { + const k = vKeys[i]; + if (k === key) { + return vVals[i]; + } + if (k === -1) { + break; + } + i = (i + 1) & vMask; + } + + let px = originX + vx * voxelResolution; + let py = originY + vy * voxelResolution; + let pz = originZ + vz * voxelResolution; + + // Place vertex at edge midpoint (binary field -> always at 0.5) + if (axis === 0) { + px += voxelResolution * 0.5; + } else if (axis === 1) { + py += voxelResolution * 0.5; + } else { + pz += voxelResolution * 0.5; + } + + const idx = addPosition(px, py, pz); + addSplitPointForVertex(vx, vy, vz, axis); + vKeys[i] = key; + vVals[i] = idx; + vSize++; + if (vSize > ((vCap * 0.7) | 0)) { + vGrow(); + } + return idx; + } + + // Full-face MC cases can be merged before vertex creation. Encode each + // unit face cell as a sortable integer in Float64: bucket / plane / u / v, + // where bucket = axis*2 + positiveNormalBit and coordinates are offset by + // +1 to cover the -1 boundary extension. + const faceCoordStride = Math.max(gridNx, gridNy, gridNz) + 3; + let faceCellCap = 0; + let faceCellLen = 0; + let faceCellKeys = new Float64Array(0); + const diagCoordStride = (Math.max(gridNx, gridNy, gridNz) + 3) * 8 + 9; + const diagCoordOffset = Math.floor(diagCoordStride / 2); + let diagCellCap = 0; + let diagCellLen = 0; + let diagCellKeys = new Float64Array(0); + + function addFaceCell(bucket: number, p: number, u: number, v: number): void { + if (faceCellLen === faceCellCap) { + faceCellCap = faceCellCap === 0 ? 1024 : faceCellCap * 2; + const grown = new Float64Array(faceCellCap); + grown.set(faceCellKeys); + faceCellKeys = grown; + } + faceCellKeys[faceCellLen++] = + ((bucket * faceCoordStride + (p + 1)) * faceCoordStride + (u + 1)) * faceCoordStride + (v + 1); + } + + function addDiagCell(bucket: number, plane: number, u: number, e: number): void { + if (diagCellLen === diagCellCap) { + diagCellCap = diagCellCap === 0 ? 1024 : diagCellCap * 2; + const grown = new Float64Array(diagCellCap); + grown.set(diagCellKeys); + diagCellKeys = grown; + } + diagCellKeys[diagCellLen++] = + ((bucket * diagCoordStride + (plane + diagCoordOffset)) * diagCoordStride + (u + diagCoordOffset)) * + diagCoordStride + + (e + diagCoordOffset); + } + + function collectFlatFace(cubeIndex: number, vx: number, vy: number, vz: number): boolean { + if (!mergeFlatFaces) { + return false; + } + + switch (cubeIndex) { + case 153: // low-X corners occupied, high-X corners empty => +X normal + addFaceCell(1, vx, vy, vz); + return true; + case 102: // high-X occupied => -X normal + addFaceCell(0, vx, vy, vz); + return true; + case 51: // low-Y occupied => +Y normal + addFaceCell(3, vy, vx, vz); + return true; + case 204: // high-Y occupied => -Y normal + addFaceCell(2, vy, vx, vz); + return true; + case 15: // low-Z occupied => +Z normal + addFaceCell(5, vz, vx, vy); + return true; + case 240: // high-Z occupied => -Z normal + addFaceCell(4, vz, vx, vy); + return true; + default: + return false; + } + } + + function scaledFacePoint(axis: number, p: number, u: number, v: number): [number, number, number] { + if (axis === 0) { + return [p * 2 + 1, u * 2, v * 2]; + } + if (axis === 1) { + return [u * 2, p * 2 + 1, v * 2]; + } + return [u * 2, v * 2, p * 2 + 1]; + } + + function diagBucket(axisA: number, axisB: number, signA: number, signB: number): number { + const pair = axisA === 0 ? (axisB === 1 ? 0 : 1) : 2; + const signBits = (signA > 0 ? 1 : 0) | (signB > 0 ? 2 : 0); + return pair * 4 + signBits; + } + + function decodeDiagBucket(bucket: number): { + axisA: number; + axisB: number; + axisE: number; + signA: number; + signB: number; + } { + const pair = (bucket / 4) | 0; + const signBits = bucket & 3; + const signA = (signBits & 1) !== 0 ? 1 : -1; + const signB = (signBits & 2) !== 0 ? 1 : -1; + if (pair === 0) { + return { axisA: 0, axisB: 1, axisE: 2, signA, signB }; + } + if (pair === 1) { + return { axisA: 0, axisB: 2, axisE: 1, signA, signB }; + } + return { axisA: 1, axisB: 2, axisE: 0, signA, signB }; + } + + function coordByAxis(x: number, y: number, z: number, axis: number): number { + if (axis === 0) { + return x; + } + if (axis === 1) { + return y; + } + return z; + } + + function diagPoint(bucket: number, plane: number, u: number, e: number): [number, number, number] { + const { axisA, axisB, axisE, signA, signB } = decodeDiagBucket(bucket); + const out = [0, 0, 0]; + out[axisA] = signA * ((plane + u) / 2); + out[axisB] = signB * ((plane - u) / 2); + out[axisE] = e; + return [out[0], out[1], out[2]]; + } + + function edgeScaledPoint(edge: number, vx: number, vy: number, vz: number, out: Int32Array, offset: number): void { + const x = vx * 2; + const y = vy * 2; + const z = vz * 2; + switch (edge) { + case 0: + out[offset] = x + 1; + out[offset + 1] = y; + out[offset + 2] = z; + break; + case 1: + out[offset] = x + 2; + out[offset + 1] = y + 1; + out[offset + 2] = z; + break; + case 2: + out[offset] = x + 1; + out[offset + 1] = y + 2; + out[offset + 2] = z; + break; + case 3: + out[offset] = x; + out[offset + 1] = y + 1; + out[offset + 2] = z; + break; + case 4: + out[offset] = x + 1; + out[offset + 1] = y; + out[offset + 2] = z + 2; + break; + case 5: + out[offset] = x + 2; + out[offset + 1] = y + 1; + out[offset + 2] = z + 2; + break; + case 6: + out[offset] = x + 1; + out[offset + 1] = y + 2; + out[offset + 2] = z + 2; + break; + case 7: + out[offset] = x; + out[offset + 1] = y + 1; + out[offset + 2] = z + 2; + break; + case 8: + out[offset] = x; + out[offset + 1] = y; + out[offset + 2] = z + 1; + break; + case 9: + out[offset] = x + 2; + out[offset + 1] = y; + out[offset + 2] = z + 1; + break; + case 10: + out[offset] = x + 2; + out[offset + 1] = y + 2; + out[offset + 2] = z + 1; + break; + default: + out[offset] = x; + out[offset + 1] = y + 2; + out[offset + 2] = z + 1; + break; + } + } + + const pairVerts = new Int32Array(18); + const uniqueVerts = new Int32Array(12); + + function samePoint(src: Int32Array, a: number, b: number): boolean { + return src[a] === src[b] && src[a + 1] === src[b + 1] && src[a + 2] === src[b + 2]; + } + + function pointInUnique(x: number, y: number, z: number, uniqueCount: number): boolean { + for (let i = 0; i < uniqueCount; i++) { + const o = i * 3; + if (uniqueVerts[o] === x && uniqueVerts[o + 1] === y && uniqueVerts[o + 2] === z) { + return true; + } + } + return false; + } + + function collectDiagPair( + triRow: number[], + triA: number, + triB: number, + vx: number, + vy: number, + vz: number, + ): boolean { + const edgesA = triA * 3; + const edgesB = triB * 3; + edgeScaledPoint(triRow[edgesA], vx, vy, vz, pairVerts, 0); + edgeScaledPoint(triRow[edgesA + 2], vx, vy, vz, pairVerts, 3); + edgeScaledPoint(triRow[edgesA + 1], vx, vy, vz, pairVerts, 6); + edgeScaledPoint(triRow[edgesB], vx, vy, vz, pairVerts, 9); + edgeScaledPoint(triRow[edgesB + 2], vx, vy, vz, pairVerts, 12); + edgeScaledPoint(triRow[edgesB + 1], vx, vy, vz, pairVerts, 15); + + let uniqueCount = 0; + for (let i = 0; i < 6; i++) { + const src = i * 3; + let found = false; + for (let j = 0; j < uniqueCount; j++) { + const dst = j * 3; + if ( + pairVerts[src] === uniqueVerts[dst] && + pairVerts[src + 1] === uniqueVerts[dst + 1] && + pairVerts[src + 2] === uniqueVerts[dst + 2] + ) { + found = true; + break; + } + } + if (!found) { + if (uniqueCount === 4) { + return false; + } + const dst = uniqueCount * 3; + uniqueVerts[dst] = pairVerts[src]; + uniqueVerts[dst + 1] = pairVerts[src + 1]; + uniqueVerts[dst + 2] = pairVerts[src + 2]; + uniqueCount++; + } + } + if (uniqueCount !== 4) { + return false; + } + + const ax = pairVerts[3] - pairVerts[0]; + const ay = pairVerts[4] - pairVerts[1]; + const az = pairVerts[5] - pairVerts[2]; + const bx = pairVerts[6] - pairVerts[0]; + const by = pairVerts[7] - pairVerts[1]; + const bz = pairVerts[8] - pairVerts[2]; + const normal = [ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx]; + const absNormal = [Math.abs(normal[0]), Math.abs(normal[1]), Math.abs(normal[2])]; + let axisE = -1; + let axisA = -1; + let axisB = -1; + for (let i = 0; i < 3; i++) { + if (absNormal[i] === 0) { + axisE = i; + } else if (axisA === -1) { + axisA = i; + } else { + axisB = i; + } + } + if (axisE === -1 || axisA === -1 || axisB === -1) { + return false; + } + if (absNormal[axisA] !== absNormal[axisB]) { + return false; + } + + const signA = normal[axisA] > 0 ? 1 : -1; + const signB = normal[axisB] > 0 ? 1 : -1; + const bucket = diagBucket(axisA, axisB, signA, signB); + const plane = + signA * coordByAxis(uniqueVerts[0], uniqueVerts[1], uniqueVerts[2], axisA) + + signB * coordByAxis(uniqueVerts[0], uniqueVerts[1], uniqueVerts[2], axisB); + let minU = Infinity; + let maxU = -Infinity; + let minE = Infinity; + let maxE = -Infinity; + for (let i = 0; i < uniqueCount; i++) { + const o = i * 3; + const a = coordByAxis(uniqueVerts[o], uniqueVerts[o + 1], uniqueVerts[o + 2], axisA); + const b = coordByAxis(uniqueVerts[o], uniqueVerts[o + 1], uniqueVerts[o + 2], axisB); + const e = coordByAxis(uniqueVerts[o], uniqueVerts[o + 1], uniqueVerts[o + 2], axisE); + if (signA * a + signB * b !== plane) { + return false; + } + const u = signA * a - signB * b; + if (u < minU) { + minU = u; + } + if (u > maxU) { + maxU = u; + } + if (e < minE) { + minE = e; + } + if (e > maxE) { + maxE = e; + } + } + if (maxU - minU !== 2 || maxE - minE !== 2) { + return false; + } + + const p00 = diagPoint(bucket, plane, minU, minE); + const p10 = diagPoint(bucket, plane, maxU, minE); + const p11 = diagPoint(bucket, plane, maxU, maxE); + const p01 = diagPoint(bucket, plane, minU, maxE); + if ( + !pointInUnique(p00[0], p00[1], p00[2], uniqueCount) || + !pointInUnique(p10[0], p10[1], p10[2], uniqueCount) || + !pointInUnique(p11[0], p11[1], p11[2], uniqueCount) || + !pointInUnique(p01[0], p01[1], p01[2], uniqueCount) + ) { + return false; + } + + let sharedCount = 0; + let sharedU0 = 0; + let sharedE0 = 0; + let sharedU1 = 0; + let sharedE1 = 0; + for (let i = 0; i < 3; i++) { + const oi = i * 3; + for (let j = 3; j < 6; j++) { + const oj = j * 3; + if (!samePoint(pairVerts, oi, oj)) { + continue; + } + const a = coordByAxis(pairVerts[oi], pairVerts[oi + 1], pairVerts[oi + 2], axisA); + const b = coordByAxis(pairVerts[oi], pairVerts[oi + 1], pairVerts[oi + 2], axisB); + const e = coordByAxis(pairVerts[oi], pairVerts[oi + 1], pairVerts[oi + 2], axisE); + if (sharedCount === 0) { + sharedU0 = signA * a - signB * b; + sharedE0 = e; + } else { + sharedU1 = signA * a - signB * b; + sharedE1 = e; + } + sharedCount++; + } + } + if (sharedCount !== 2 || sharedU0 === sharedU1 || sharedE0 === sharedE1) { + return false; + } + + addDiagCell(bucket, plane, minU, minE); + return true; + } + + function collectDiagFaces(triRow: number[], vx: number, vy: number, vz: number): number { + if (!mergeFlatFaces) { + return 0; + } + const triCount = (triRow.length / 3) | 0; + let usedMask = 0; + for (let i = 0; i < triCount; i++) { + if ((usedMask & (1 << i)) !== 0) { + continue; + } + for (let j = i + 1; j < triCount; j++) { + if ((usedMask & (1 << j)) !== 0) { + continue; + } + if (collectDiagPair(triRow, i, j, vx, vy, vz)) { + usedMask |= (1 << i) | (1 << j); + break; + } + } + } + return usedMask; + } + + function getScaledVertex(x2: number, y2: number, z2: number): number { + if ((x2 & 1) !== 0) { + return getVertex((x2 - 1) / 2, y2 / 2, z2 / 2, 0); + } + if ((y2 & 1) !== 0) { + return getVertex(x2 / 2, (y2 - 1) / 2, z2 / 2, 1); + } + return getVertex(x2 / 2, y2 / 2, (z2 - 1) / 2, 2); + } + + let perimeterScratch = new Uint32Array(16); + let perimeterU = new Int32Array(16); + let perimeterV = new Int32Array(16); + let perimeterLen = 0; + + function localFaceUv(axis: number, x2: number, y2: number, z2: number): [number, number] { + if (axis === 0) { + return [y2, z2]; + } + if (axis === 1) { + return [x2, z2]; + } + return [x2, y2]; + } + + function addPerimeterVertex(vertex: number, u: number, v: number): void { + if (perimeterLen > 0 && perimeterScratch[perimeterLen - 1] === vertex) { + return; + } + if (perimeterLen === perimeterScratch.length) { + const grown = new Uint32Array(perimeterScratch.length * 2); + grown.set(perimeterScratch); + perimeterScratch = grown; + const grownU = new Int32Array(perimeterU.length * 2); + grownU.set(perimeterU); + perimeterU = grownU; + const grownV = new Int32Array(perimeterV.length * 2); + grownV.set(perimeterV); + perimeterV = grownV; + } + perimeterScratch[perimeterLen] = vertex; + perimeterU[perimeterLen] = u; + perimeterV[perimeterLen] = v; + perimeterLen++; + } + + function addPerimeterPoint(axis: number, x2: number, y2: number, z2: number): void { + const [u, v] = localFaceUv(axis, x2, y2, z2); + addPerimeterVertex(getScaledVertex(x2, y2, z2), u, v); + } + + function addDiagPerimeterPoint(bucket: number, x2: number, y2: number, z2: number): void { + const { axisA, axisB, axisE, signA, signB } = decodeDiagBucket(bucket); + const a = coordByAxis(x2, y2, z2, axisA); + const b = coordByAxis(x2, y2, z2, axisB); + const e = coordByAxis(x2, y2, z2, axisE); + addPerimeterVertex(getScaledVertex(x2, y2, z2), signA * a - signB * b, e); + } + + function addSplitSegment(x0: number, y0: number, z0: number, x1: number, y1: number, z1: number): void { + const changes = (x0 !== x1 ? 1 : 0) + (y0 !== y1 ? 1 : 0) + (z0 !== z1 ? 1 : 0); + if (changes !== 1) { + return; + } + if (x0 !== x1) { + const key = splitLineKey(0, x0, y0, z0); + addSplitLinePoint(key, x0); + addSplitLinePoint(key, x1); + } else if (y0 !== y1) { + const key = splitLineKey(1, x0, y0, z0); + addSplitLinePoint(key, y0); + addSplitLinePoint(key, y1); + } else { + const key = splitLineKey(2, x0, y0, z0); + addSplitLinePoint(key, z0); + addSplitLinePoint(key, z1); + } + } + + function addSplitEdgeVertices( + axis: number, + x0: number, + y0: number, + z0: number, + x1: number, + y1: number, + z1: number, + addPoint?: (x2: number, y2: number, z2: number) => void, + ): void { + function addDefaultPerimeterPoint(px: number, py: number, pz: number): void { + addPerimeterPoint(axis, px, py, pz); + } + const emit = addPoint ?? addDefaultPerimeterPoint; + + let varAxis: number; + let start: number; + let end: number; + if (x0 !== x1) { + varAxis = 0; + start = x0; + end = x1; + } else if (y0 !== y1) { + varAxis = 1; + start = y0; + end = y1; + } else { + varAxis = 2; + start = z0; + end = z1; + } + + const points = splitLinePoints?.get(splitLineKey(varAxis, x0, y0, z0)); + if (!points) { + return; + } + + const lo = Math.min(start, end); + const hi = Math.max(start, end); + const forward = start <= end; + + function emitPoint(t: number): void { + if (varAxis === 0) { + emit(t, y0, z0); + } else if (varAxis === 1) { + emit(x0, t, z0); + } else { + emit(x0, y0, t); + } + } + + if (forward) { + for (let i = 0; i < points.length; i++) { + const t = points[i]; + if (t < lo) { + continue; + } + if (t > hi) { + break; + } + emitPoint(t); + } + } else { + for (let i = points.length - 1; i >= 0; i--) { + const t = points[i]; + if (t > hi) { + continue; + } + if (t < lo) { + break; + } + emitPoint(t); + } + } + } + + function appendOrientedTri(a: number, b: number, c: number, useLocalCcw: boolean): void { + if (useLocalCcw) { + appendTri(a, b, c); + } else { + appendTri(a, c, b); + } + } + + function appendPerimeterTri(a: number, b: number, c: number, useLocalCcw: boolean): void { + const abx = perimeterU[b] - perimeterU[a]; + const aby = perimeterV[b] - perimeterV[a]; + const acx = perimeterU[c] - perimeterU[a]; + const acy = perimeterV[c] - perimeterV[a]; + if (abx * acy - aby * acx <= 0) { + return; + } + appendOrientedTri(perimeterScratch[a], perimeterScratch[b], perimeterScratch[c], useLocalCcw); + } + + function triangulateTwoSideChain( + chainAStart: number, + chainAEnd: number, + chainBStart: number, + chainBEnd: number, + useLocalCcw: boolean, + ): void { + const chainALen = chainAEnd - chainAStart; + const chainBLen = chainBEnd - chainBStart; + if (chainALen < 2 || chainBLen < 2) { + return; + } + + const v2 = chainBEnd - 1; + for (let i = chainAStart; i < chainAEnd - 2; i++) { + appendPerimeterTri(v2, i, i + 1, useLocalCcw); + } + + const pivot = chainALen > 1 ? chainAEnd - 2 : chainAStart; + for (let i = chainBStart; i < chainBEnd - 1; i++) { + appendPerimeterTri(pivot, i, i + 1, useLocalCcw); + } + } + + function emitFaceRectangle(bucket: number, p: number, u0: number, v0: number, u1: number, v1: number): void { + const axis = bucket >> 1; + const positive = (bucket & 1) === 1; + const a = scaledFacePoint(axis, p, u0, v0); + const b = scaledFacePoint(axis, p, u1, v0); + const c = scaledFacePoint(axis, p, u1, v1); + const d = scaledFacePoint(axis, p, u0, v1); + + perimeterLen = 0; + const side0Start = 0; + addSplitEdgeVertices(axis, a[0], a[1], a[2], b[0], b[1], b[2]); + const side0End = perimeterLen; + const side1Start = side0End - 1; + addSplitEdgeVertices(axis, b[0], b[1], b[2], c[0], c[1], c[2]); + const side1End = perimeterLen; + const side2Start = side1End - 1; + addSplitEdgeVertices(axis, c[0], c[1], c[2], d[0], d[1], d[2]); + const side2End = perimeterLen; + const side3Start = side2End - 1; + addSplitEdgeVertices(axis, d[0], d[1], d[2], a[0], a[1], a[2]); + const side3End = perimeterLen; + + const localCcwIsPositive = axis !== 1; + const useLocalCcw = positive === localCcwIsPositive; + triangulateTwoSideChain(side0Start, side0End, side1Start, side1End, useLocalCcw); + triangulateTwoSideChain(side2Start, side2End, side3Start, side3End, useLocalCcw); + } + + function emitDiagRectangle(bucket: number, plane: number, u0: number, e0: number, u1: number, e1: number): void { + const a = diagPoint(bucket, plane, u0, e0); + const b = diagPoint(bucket, plane, u1, e0); + const c = diagPoint(bucket, plane, u1, e1); + const d = diagPoint(bucket, plane, u0, e1); + function addPoint(x2: number, y2: number, z2: number): void { + addDiagPerimeterPoint(bucket, x2, y2, z2); + } + + perimeterLen = 0; + const side0Start = 0; + addPoint(a[0], a[1], a[2]); + addPoint(b[0], b[1], b[2]); + const side0End = perimeterLen; + const side1Start = side0End - 1; + addSplitEdgeVertices(0, b[0], b[1], b[2], c[0], c[1], c[2], addPoint); + const side1End = perimeterLen; + const side2Start = side1End - 1; + addPoint(d[0], d[1], d[2]); + const side2End = perimeterLen; + const side3Start = side2End - 1; + addSplitEdgeVertices(0, d[0], d[1], d[2], a[0], a[1], a[2], addPoint); + const side3End = perimeterLen; + + const { axisA, axisB, signA, signB } = decodeDiagBucket(bucket); + const abx = b[0] - a[0]; + const aby = b[1] - a[1]; + const abz = b[2] - a[2]; + const bcx = c[0] - b[0]; + const bcy = c[1] - b[1]; + const bcz = c[2] - b[2]; + const nx = aby * bcz - abz * bcy; + const ny = abz * bcx - abx * bcz; + const nz = abx * bcy - aby * bcx; + const normal = [0, 0, 0]; + normal[axisA] = signA; + normal[axisB] = signB; + const useLocalCcw = nx * normal[0] + ny * normal[1] + nz * normal[2] > 0; + + triangulateTwoSideChain(side0Start, side0End, side1Start, side1End, useLocalCcw); + triangulateTwoSideChain(side2Start, side2End, side3Start, side3End, useLocalCcw); + } + + function flushFaceCells(): void { + if (faceCellLen === 0 && diagCellLen === 0) { + return; + } + + const keys = faceCellKeys.subarray(0, faceCellLen); + faceCellKeys = new Float64Array(0); + keys.sort(); + + let rectCap = 1024; + let rectLen = 0; + let rectBucket = new Int32Array(rectCap); + let rectP = new Int32Array(rectCap); + let rectU0 = new Int32Array(rectCap); + let rectV0 = new Int32Array(rectCap); + let rectU1 = new Int32Array(rectCap); + let rectV1 = new Int32Array(rectCap); + let diagRectCap = 1024; + let diagRectLen = 0; + let diagRectBucket = new Int32Array(diagRectCap); + let diagRectPlane = new Int32Array(diagRectCap); + let diagRectU0 = new Int32Array(diagRectCap); + let diagRectE0 = new Int32Array(diagRectCap); + let diagRectU1 = new Int32Array(diagRectCap); + let diagRectE1 = new Int32Array(diagRectCap); + + function addRect(bucket: number, p: number, u0: number, v0: number, u1: number, v1: number): void { + if (rectLen === rectCap) { + rectCap *= 2; + function grow(src: Int32Array): Int32Array { + const out = new Int32Array(rectCap); + out.set(src); + return out; + } + rectBucket = grow(rectBucket); + rectP = grow(rectP); + rectU0 = grow(rectU0); + rectV0 = grow(rectV0); + rectU1 = grow(rectU1); + rectV1 = grow(rectV1); + } + rectBucket[rectLen] = bucket; + rectP[rectLen] = p; + rectU0[rectLen] = u0; + rectV0[rectLen] = v0; + rectU1[rectLen] = u1; + rectV1[rectLen] = v1; + rectLen++; + } + + function addDiagRect(bucket: number, plane: number, u0: number, e0: number, u1: number, e1: number): void { + if (diagRectLen === diagRectCap) { + diagRectCap *= 2; + function grow(src: Int32Array): Int32Array { + const out = new Int32Array(diagRectCap); + out.set(src); + return out; + } + diagRectBucket = grow(diagRectBucket); + diagRectPlane = grow(diagRectPlane); + diagRectU0 = grow(diagRectU0); + diagRectE0 = grow(diagRectE0); + diagRectU1 = grow(diagRectU1); + diagRectE1 = grow(diagRectE1); + } + diagRectBucket[diagRectLen] = bucket; + diagRectPlane[diagRectLen] = plane; + diagRectU0[diagRectLen] = u0; + diagRectE0[diagRectLen] = e0; + diagRectU1[diagRectLen] = u1; + diagRectE1[diagRectLen] = e1; + diagRectLen++; + } + + function decodeGroup(key: number): { bucket: number; pOff: number } { + let q = Math.floor(key / faceCoordStride); + q = Math.floor(q / faceCoordStride); + const pOff = q % faceCoordStride; + const bucket = Math.floor(q / faceCoordStride); + return { bucket, pOff }; + } + + function decodeUvKey(key: number): number { + const vOff = key % faceCoordStride; + const q = Math.floor(key / faceCoordStride); + const uOff = q % faceCoordStride; + return uOff * faceCoordStride + vOff; + } + + let start = 0; + while (start < keys.length) { + const { bucket, pOff } = decodeGroup(keys[start]); + let end = start + 1; + while (end < keys.length) { + const g = decodeGroup(keys[end]); + if (g.bucket !== bucket || g.pOff !== pOff) { + break; + } + end++; + } + + const count = end - start; + let hCap = 1; + while (hCap < count / 0.7) { + hCap *= 2; + } + const hMask = hCap - 1; + const hKeys = new Float64Array(hCap).fill(-1); + const hVals = new Int32Array(hCap); + + function hash(key: number): number { + const hi = (key / 0x100000000) | 0; + return (Math.imul((key | 0) ^ hi, 0x9e3779b9) >>> 0) & hMask; + } + + for (let i = 0; i < count; i++) { + const uvKey = decodeUvKey(keys[start + i]); + let h = hash(uvKey); + while (hKeys[h] !== -1) { + h = (h + 1) & hMask; + } + hKeys[h] = uvKey; + hVals[h] = i; + } + + function lookup(uvKey: number): number { + let h = hash(uvKey); + while (true) { + const k = hKeys[h]; + if (k === uvKey) { + return hVals[h]; + } + if (k === -1) { + return -1; + } + h = (h + 1) & hMask; + } + } + + const visited = new Uint8Array(count); + function uvKeyOf(uOff: number, vOff: number): number { + return uOff * faceCoordStride + vOff; + } + const p = pOff - 1; + + for (let i = 0; i < count; i++) { + if (visited[i]) { + continue; + } + const uvKey = decodeUvKey(keys[start + i]); + const uOff = Math.floor(uvKey / faceCoordStride); + const vOff = uvKey % faceCoordStride; + + let width = 1; + while (true) { + const idx = lookup(uvKeyOf(uOff + width, vOff)); + if (idx === -1 || visited[idx]) { + break; + } + width++; + } + + let height = 1; + while (true) { + let canGrow = true; + for (let du = 0; du < width; du++) { + const idx = lookup(uvKeyOf(uOff + du, vOff + height)); + if (idx === -1 || visited[idx]) { + canGrow = false; + break; + } + } + if (!canGrow) { + break; + } + height++; + } + + for (let dv = 0; dv < height; dv++) { + for (let du = 0; du < width; du++) { + const idx = lookup(uvKeyOf(uOff + du, vOff + dv)); + visited[idx] = 1; + } + } + + addRect(bucket, p, uOff - 1, vOff - 1, uOff - 1 + width, vOff - 1 + height); + } + + start = end; + } + + if (diagCellLen > 0) { + const diagKeys = diagCellKeys.slice(0, diagCellLen); + diagCellKeys = new Float64Array(0); + diagKeys.sort(); + + function decodeDiagKey(key: number): { + bucket: number; + plane: number; + u: number; + e: number; + } { + const eOff = key % diagCoordStride; + let q = Math.floor(key / diagCoordStride); + const uOff = q % diagCoordStride; + q = Math.floor(q / diagCoordStride); + const planeOff = q % diagCoordStride; + const bucket = Math.floor(q / diagCoordStride); + return { + bucket, + plane: planeOff - diagCoordOffset, + u: uOff - diagCoordOffset, + e: eOff - diagCoordOffset, + }; + } + + let diagStart = 0; + while (diagStart < diagKeys.length) { + const first = decodeDiagKey(diagKeys[diagStart]); + let diagEnd = diagStart + 1; + while (diagEnd < diagKeys.length) { + const next = decodeDiagKey(diagKeys[diagEnd]); + if (next.bucket !== first.bucket || next.plane !== first.plane || next.u !== first.u) { + break; + } + diagEnd++; + } + + let i = diagStart; + while (i < diagEnd) { + const run = decodeDiagKey(diagKeys[i]); + let e1 = run.e + 2; + i++; + while (i < diagEnd) { + const next = decodeDiagKey(diagKeys[i]); + if (next.e !== e1) { + break; + } + e1 += 2; + i++; + } + addDiagRect(run.bucket, run.plane, run.u, run.e, run.u + 2, e1); + } + + diagStart = diagEnd; + } + } + + for (let r = 0; r < rectLen; r++) { + const axis = rectBucket[r] >> 1; + const p = rectP[r]; + const u0 = rectU0[r]; + const v0 = rectV0[r]; + const u1 = rectU1[r]; + const v1 = rectV1[r]; + const a = scaledFacePoint(axis, p, u0, v0); + const b = scaledFacePoint(axis, p, u1, v0); + const c = scaledFacePoint(axis, p, u1, v1); + const d = scaledFacePoint(axis, p, u0, v1); + addSplitSegment(a[0], a[1], a[2], b[0], b[1], b[2]); + addSplitSegment(b[0], b[1], b[2], c[0], c[1], c[2]); + addSplitSegment(c[0], c[1], c[2], d[0], d[1], d[2]); + addSplitSegment(d[0], d[1], d[2], a[0], a[1], a[2]); + } + + for (let r = 0; r < diagRectLen; r++) { + const bucket = diagRectBucket[r]; + const plane = diagRectPlane[r]; + const u0 = diagRectU0[r]; + const e0 = diagRectE0[r]; + const u1 = diagRectU1[r]; + const e1 = diagRectE1[r]; + const a = diagPoint(bucket, plane, u0, e0); + const b = diagPoint(bucket, plane, u1, e0); + const c = diagPoint(bucket, plane, u1, e1); + const d = diagPoint(bucket, plane, u0, e1); + addSplitSegment(a[0], a[1], a[2], b[0], b[1], b[2]); + addSplitSegment(b[0], b[1], b[2], c[0], c[1], c[2]); + addSplitSegment(c[0], c[1], c[2], d[0], d[1], d[2]); + addSplitSegment(d[0], d[1], d[2], a[0], a[1], a[2]); + } + + if (splitLinePoints) { + for (const points of splitLinePoints.values()) { + points.sort((a, b) => a - b); + let write = 0; + for (let i = 0; i < points.length; i++) { + if (i === 0 || points[i] !== points[i - 1]) { + points[write++] = points[i]; + } + } + points.length = write; + } + } + + collectSplitPoints = false; + for (let r = 0; r < rectLen; r++) { + emitFaceRectangle(rectBucket[r], rectP[r], rectU0[r], rectV0[r], rectU1[r], rectV1[r]); + } + for (let r = 0; r < diagRectLen; r++) { + emitDiagRectangle( + diagRectBucket[r], + diagRectPlane[r], + diagRectU0[r], + diagRectE0[r], + diagRectU1[r], + diagRectE1[r], + ); + } + splitLinePoints?.clear(); + } + + // Track processed orphan cells to avoid duplicate triangles. When a cell's + // owner block doesn't exist, multiple neighboring blocks can reach it via + // the -1 boundary extension. The hash table ensures each orphan cell is + // only processed once. Same typed-array structure as the vertex hash — + // see `oKeys` / `oGrow` above. Stored separately because keys collide with + // vertex keys (same encoding, different namespace). + + // Iterate non-empty blocks of the grid. Local SparseVoxelGrid stores one + // byte per block type instead of upstream's packed 2-bit type words. + // Each block is processed once; the inner loop also handles boundary cells + // that straddle into neighboring blocks (so we don't need a separate pass + // for orphan boundary cells along the negative grid edges). + for (let w = 0; w < types.length; w++) { + const word = types[w]; + if (word === 0) { + continue; + } + let nonEmpty = ((word & EVEN_BITS) | ((word >>> 1) & EVEN_BITS)) >>> 0; + const baseBlockIdx = w * BLOCKS_PER_WORD; + while (nonEmpty) { + const bp = 31 - Math.clz32(nonEmpty & -nonEmpty); + const lane = bp >>> 1; + const blockIdx = baseBlockIdx + lane; + nonEmpty &= nonEmpty - 1; + if (blockIdx >= totalBlocks) { + break; + } + + // Decode block coordinates. + bx = blockIdx % nbx; + const byBz = (blockIdx / nbx) | 0; + by = byBz % nby; + bz = (byBz / nby) | 0; + + // Populate the 3x3x3 neighbor table for this block. After this loop, + // every per-cell occupancy query is a direct typed-array index. + // + // Track `allNeighborsSolid` so we can skip the entire cell loop + // for blocks deep inside an obstruction, where every cubeIndex is + // 255 and no triangles are emitted. On large carved scenes this + // is the bulk of SOLID blocks and dominates marching-cubes runtime. + let currentBlockIsSolid = false; + let allNeighborsSolid = true; + for (let dz = -1; dz <= 1; dz++) { + const nbZ = bz + dz; + for (let dy = -1; dy <= 1; dy++) { + const nbY = by + dy; + for (let dx = -1; dx <= 1; dx++) { + const nbX = bx + dx; + const slot = dx + 1 + (dy + 1) * 3 + (dz + 1) * 9; + if (nbX < 0 || nbY < 0 || nbZ < 0 || nbX >= nbx || nbY >= nby || nbZ >= nbz) { + neighborEntry[slot] = NEIGHBOR_EMPTY; + allNeighborsSolid = false; + continue; + } + const nbIdx = nbX + nbY * nbx + nbZ * bStride; + const bt = readBlockType(types, nbIdx); + if (bt === BLOCK_EMPTY) { + neighborEntry[slot] = NEIGHBOR_EMPTY; + allNeighborsSolid = false; + } else if (bt === BLOCK_SOLID) { + neighborEntry[slot] = NEIGHBOR_SOLID; + if (dx === 0 && dy === 0 && dz === 0) { + currentBlockIsSolid = true; + } + } else { + neighborEntry[slot] = NEIGHBOR_MIXED; + allNeighborsSolid = false; + const ms = masks.slot(nbIdx); + neighborMasks[slot * 2] = masks.lo[ms]; + neighborMasks[slot * 2 + 1] = masks.hi[ms]; + } + } + } + } + + // Block is fully interior to a solid region — every cubeIndex is + // 255, every cell would emit 0 triangles. Skip the cell loop. + if (currentBlockIsSolid && allNeighborsSolid) { + continue; + } + + // Iterate cell origins from -1 through 3 on each axis. The -1 and + // 3 layers straddle block edges and close surfaces where no + // neighboring block exists. + for (let lz = -1; lz < 4; lz++) { + const lzInside = lz >= 0 && lz <= 2; + for (let ly = -1; ly < 4; ly++) { + const lyInside = ly >= 0 && ly <= 2; + for (let lx = -1; lx < 4; lx++) { + // For solid blocks, the 27 cells with all axes in 0..2 + // are fully inside the block. All 8 corners are 1 so + // cubeIndex == 255 and no triangles are emitted -- skip. + if (currentBlockIsSolid && lzInside && lyInside && lx >= 0 && lx <= 2) { + continue; + } + + const vx = bx * 4 + lx; + const vy = by * 4 + ly; + const vz = bz * 4 + lz; + + // Determine which block owns this cell + const ownerBx = vx >> 2; + const ownerBy = vy >> 2; + const ownerBz = vz >> 2; + + if (ownerBx !== bx || ownerBy !== by || ownerBz !== bz) { + // Cell belongs to a different block — skip if that + // block is non-empty (it will process the cell itself). + if ( + ownerBx >= 0 && + ownerBy >= 0 && + ownerBz >= 0 && + ownerBx < nbx && + ownerBy < nby && + ownerBz < nbz + ) { + const ownerIdx = ownerBx + ownerBy * nbx + ownerBz * bStride; + if (readBlockType(types, ownerIdx) !== BLOCK_EMPTY) { + continue; + } + } + + // Owner block doesn't exist or is out-of-bounds — + // deduplicate so only the first neighboring block to + // reach this cell emits triangles. + const cellKey = vx + 1 + (vy + 1) * strideX + (vz + 1) * strideXY; + let oi = (Math.imul(cellKey | 0, 0x9e3779b9) >>> 0) & oMask; + let oFound = false; + while (true) { + const ok = oKeys[oi]; + if (ok === cellKey) { + oFound = true; + break; + } + if (ok === -1) { + break; + } + oi = (oi + 1) & oMask; + } + if (oFound) { + continue; + } + oKeys[oi] = cellKey; + oSize++; + if (oSize > ((oCap * 0.7) | 0)) { + oGrow(); + } + } + + // Get corner values for this cell (8 corners) + // Corners: (vx,vy,vz), (vx+1,vy,vz), (vx+1,vy+1,vz), (vx,vy+1,vz), + // (vx,vy,vz+1), (vx+1,vy,vz+1), (vx+1,vy+1,vz+1), (vx,vy+1,vz+1) + const c0 = isOccupiedLocal(vx, vy, vz) ? 1 : 0; + const c1 = isOccupiedLocal(vx + 1, vy, vz) ? 1 : 0; + const c2 = isOccupiedLocal(vx + 1, vy + 1, vz) ? 1 : 0; + const c3 = isOccupiedLocal(vx, vy + 1, vz) ? 1 : 0; + const c4 = isOccupiedLocal(vx, vy, vz + 1) ? 1 : 0; + const c5 = isOccupiedLocal(vx + 1, vy, vz + 1) ? 1 : 0; + const c6 = isOccupiedLocal(vx + 1, vy + 1, vz + 1) ? 1 : 0; + const c7 = isOccupiedLocal(vx, vy + 1, vz + 1) ? 1 : 0; + + const cubeIndex = + c0 | (c1 << 1) | (c2 << 2) | (c3 << 3) | (c4 << 4) | (c5 << 5) | (c6 << 6) | (c7 << 7); + + if (cubeIndex === 0 || cubeIndex === 255) { + continue; + } + + if (collectFlatFace(cubeIndex, vx, vy, vz)) { + continue; + } + + const edges = EDGE_TABLE[cubeIndex]; // eslint-disable-line no-use-before-define + if (edges === 0) { + continue; + } + + const triRow = TRI_TABLE[cubeIndex]; // eslint-disable-line no-use-before-define + const triLen = triRow.length; + const usedMask = collectDiagFaces(triRow, vx, vy, vz); + let neededEdges = 0; + let emitTriLen = 0; + for (let t = 0; t < triLen; t += 3) { + const triIdx = (t / 3) | 0; + if ((usedMask & (1 << triIdx)) !== 0) { + continue; + } + neededEdges |= (1 << triRow[t]) | (1 << triRow[t + 1]) | (1 << triRow[t + 2]); + emitTriLen += 3; + } + if (neededEdges === 0) { + continue; + } + + // Compute vertices on edges used by triangles that + // were not absorbed into a merged binary-MC rectangle. + if (neededEdges & 1) { + edgeVerts[0] = getVertex(vx, vy, vz, 0); + } // edge 0: x-axis at (vx, vy, vz) + if (neededEdges & 2) { + edgeVerts[1] = getVertex(vx + 1, vy, vz, 1); + } // edge 1: y-axis at (vx+1, vy, vz) + if (neededEdges & 4) { + edgeVerts[2] = getVertex(vx, vy + 1, vz, 0); + } // edge 2: x-axis at (vx, vy+1, vz) + if (neededEdges & 8) { + edgeVerts[3] = getVertex(vx, vy, vz, 1); + } // edge 3: y-axis at (vx, vy, vz) + if (neededEdges & 16) { + edgeVerts[4] = getVertex(vx, vy, vz + 1, 0); + } // edge 4: x-axis at (vx, vy, vz+1) + if (neededEdges & 32) { + edgeVerts[5] = getVertex(vx + 1, vy, vz + 1, 1); + } // edge 5: y-axis at (vx+1, vy, vz+1) + if (neededEdges & 64) { + edgeVerts[6] = getVertex(vx, vy + 1, vz + 1, 0); + } // edge 6: x-axis at (vx, vy+1, vz+1) + if (neededEdges & 128) { + edgeVerts[7] = getVertex(vx, vy, vz + 1, 1); + } // edge 7: y-axis at (vx, vy, vz+1) + if (neededEdges & 256) { + edgeVerts[8] = getVertex(vx, vy, vz, 2); + } // edge 8: z-axis at (vx, vy, vz) + if (neededEdges & 512) { + edgeVerts[9] = getVertex(vx + 1, vy, vz, 2); + } // edge 9: z-axis at (vx+1, vy, vz) + if (neededEdges & 1024) { + edgeVerts[10] = getVertex(vx + 1, vy + 1, vz, 2); + } // edge 10: z-axis at (vx+1, vy+1, vz) + if (neededEdges & 2048) { + edgeVerts[11] = getVertex(vx, vy + 1, vz, 2); + } // edge 11: z-axis at (vx, vy+1, vz) + + // Emit triangles (reversed winding to face outward) + ensureIndexCapacity(emitTriLen); + for (let t = 0; t < triLen; t += 3) { + const triIdx = (t / 3) | 0; + if ((usedMask & (1 << triIdx)) !== 0) { + continue; + } + indices[idxLen++] = edgeVerts[triRow[t]]; + indices[idxLen++] = edgeVerts[triRow[t + 2]]; + indices[idxLen++] = edgeVerts[triRow[t + 1]]; + } + } + } + } + } + } + + flushFaceCells(); + + return { + positions: positions.slice(0, posLen), + indices: indices.slice(0, idxLen), + }; +} + +// ============================================================================ +// Marching Cubes Lookup Tables +// ============================================================================ +// Standard tables from Paul Bourke's polygonising a scalar field. +// EDGE_TABLE: 256 entries, each a 12-bit mask of which edges are intersected. +// TRI_TABLE: 256 entries, each an array of edge indices forming triangles. + +const EDGE_TABLE: number[] = [ + 0x000, 0x109, 0x203, 0x30a, 0x406, 0x50f, 0x605, 0x70c, 0x80c, 0x905, 0xa0f, 0xb06, 0xc0a, 0xd03, 0xe09, 0xf00, + 0x190, 0x099, 0x393, 0x29a, 0x596, 0x49f, 0x795, 0x69c, 0x99c, 0x895, 0xb9f, 0xa96, 0xd9a, 0xc93, 0xf99, 0xe90, + 0x230, 0x339, 0x033, 0x13a, 0x636, 0x73f, 0x435, 0x53c, 0xa3c, 0xb35, 0x83f, 0x936, 0xe3a, 0xf33, 0xc39, 0xd30, + 0x3a0, 0x2a9, 0x1a3, 0x0aa, 0x7a6, 0x6af, 0x5a5, 0x4ac, 0xbac, 0xaa5, 0x9af, 0x8a6, 0xfaa, 0xea3, 0xda9, 0xca0, + 0x460, 0x569, 0x663, 0x76a, 0x066, 0x16f, 0x265, 0x36c, 0xc6c, 0xd65, 0xe6f, 0xf66, 0x86a, 0x963, 0xa69, 0xb60, + 0x5f0, 0x4f9, 0x7f3, 0x6fa, 0x1f6, 0x0ff, 0x3f5, 0x2fc, 0xdfc, 0xcf5, 0xfff, 0xef6, 0x9fa, 0x8f3, 0xbf9, 0xaf0, + 0x650, 0x759, 0x453, 0x55a, 0x256, 0x35f, 0x055, 0x15c, 0xe5c, 0xf55, 0xc5f, 0xd56, 0xa5a, 0xb53, 0x859, 0x950, + 0x7c0, 0x6c9, 0x5c3, 0x4ca, 0x3c6, 0x2cf, 0x1c5, 0x0cc, 0xfcc, 0xec5, 0xdcf, 0xcc6, 0xbca, 0xac3, 0x9c9, 0x8c0, + 0x8c0, 0x9c9, 0xac3, 0xbca, 0xcc6, 0xdcf, 0xec5, 0xfcc, 0x0cc, 0x1c5, 0x2cf, 0x3c6, 0x4ca, 0x5c3, 0x6c9, 0x7c0, + 0x950, 0x859, 0xb53, 0xa5a, 0xd56, 0xc5f, 0xf55, 0xe5c, 0x15c, 0x055, 0x35f, 0x256, 0x55a, 0x453, 0x759, 0x650, + 0xaf0, 0xbf9, 0x8f3, 0x9fa, 0xef6, 0xfff, 0xcf5, 0xdfc, 0x2fc, 0x3f5, 0x0ff, 0x1f6, 0x6fa, 0x7f3, 0x4f9, 0x5f0, + 0xb60, 0xa69, 0x963, 0x86a, 0xf66, 0xe6f, 0xd65, 0xc6c, 0x36c, 0x265, 0x16f, 0x066, 0x76a, 0x663, 0x569, 0x460, + 0xca0, 0xda9, 0xea3, 0xfaa, 0x8a6, 0x9af, 0xaa5, 0xbac, 0x4ac, 0x5a5, 0x6af, 0x7a6, 0x0aa, 0x1a3, 0x2a9, 0x3a0, + 0xd30, 0xc39, 0xf33, 0xe3a, 0x936, 0x83f, 0xb35, 0xa3c, 0x53c, 0x435, 0x73f, 0x636, 0x13a, 0x033, 0x339, 0x230, + 0xe90, 0xf99, 0xc93, 0xd9a, 0xa96, 0xb9f, 0x895, 0x99c, 0x69c, 0x795, 0x49f, 0x596, 0x29a, 0x393, 0x099, 0x190, + 0xf00, 0xe09, 0xd03, 0xc0a, 0xb06, 0xa0f, 0x905, 0x80c, 0x70c, 0x605, 0x50f, 0x406, 0x30a, 0x203, 0x109, 0x000, +]; + +const TRI_TABLE: number[][] = [ + [], + [0, 8, 3], + [0, 1, 9], + [1, 8, 3, 9, 8, 1], + [1, 2, 10], + [0, 8, 3, 1, 2, 10], + [9, 2, 10, 0, 2, 9], + [2, 8, 3, 2, 10, 8, 10, 9, 8], + [3, 11, 2], + [0, 11, 2, 8, 11, 0], + [1, 9, 0, 2, 3, 11], + [1, 11, 2, 1, 9, 11, 9, 8, 11], + [3, 10, 1, 11, 10, 3], + [0, 10, 1, 0, 8, 10, 8, 11, 10], + [3, 9, 0, 3, 11, 9, 11, 10, 9], + [9, 8, 10, 10, 8, 11], + [4, 7, 8], + [4, 3, 0, 7, 3, 4], + [0, 1, 9, 8, 4, 7], + [4, 1, 9, 4, 7, 1, 7, 3, 1], + [1, 2, 10, 8, 4, 7], + [3, 4, 7, 3, 0, 4, 1, 2, 10], + [9, 2, 10, 9, 0, 2, 8, 4, 7], + [2, 10, 9, 2, 9, 7, 2, 7, 3, 7, 9, 4], + [8, 4, 7, 3, 11, 2], + [11, 4, 7, 11, 2, 4, 2, 0, 4], + [9, 0, 1, 8, 4, 7, 2, 3, 11], + [4, 7, 11, 9, 4, 11, 9, 11, 2, 9, 2, 1], + [3, 10, 1, 3, 11, 10, 7, 8, 4], + [1, 11, 10, 1, 4, 11, 1, 0, 4, 7, 11, 4], + [4, 7, 8, 9, 0, 11, 9, 11, 10, 11, 0, 3], + [4, 7, 11, 4, 11, 9, 9, 11, 10], + [9, 5, 4], + [9, 5, 4, 0, 8, 3], + [0, 5, 4, 1, 5, 0], + [8, 5, 4, 8, 3, 5, 3, 1, 5], + [1, 2, 10, 9, 5, 4], + [3, 0, 8, 1, 2, 10, 4, 9, 5], + [5, 2, 10, 5, 4, 2, 4, 0, 2], + [2, 10, 5, 3, 2, 5, 3, 5, 4, 3, 4, 8], + [9, 5, 4, 2, 3, 11], + [0, 11, 2, 0, 8, 11, 4, 9, 5], + [0, 5, 4, 0, 1, 5, 2, 3, 11], + [2, 1, 5, 2, 5, 8, 2, 8, 11, 4, 8, 5], + [10, 3, 11, 10, 1, 3, 9, 5, 4], + [4, 9, 5, 0, 8, 1, 8, 10, 1, 8, 11, 10], + [5, 4, 0, 5, 0, 11, 5, 11, 10, 11, 0, 3], + [5, 4, 8, 5, 8, 10, 10, 8, 11], + [9, 7, 8, 5, 7, 9], + [9, 3, 0, 9, 5, 3, 5, 7, 3], + [0, 7, 8, 0, 1, 7, 1, 5, 7], + [1, 5, 3, 3, 5, 7], + [9, 7, 8, 9, 5, 7, 10, 1, 2], + [10, 1, 2, 9, 5, 0, 5, 3, 0, 5, 7, 3], + [8, 0, 2, 8, 2, 5, 8, 5, 7, 10, 5, 2], + [2, 10, 5, 2, 5, 3, 3, 5, 7], + [7, 9, 5, 7, 8, 9, 3, 11, 2], + [9, 5, 7, 9, 7, 2, 9, 2, 0, 2, 7, 11], + [2, 3, 11, 0, 1, 8, 1, 7, 8, 1, 5, 7], + [11, 2, 1, 11, 1, 7, 7, 1, 5], + [9, 5, 8, 8, 5, 7, 10, 1, 3, 10, 3, 11], + [5, 7, 0, 5, 0, 9, 7, 11, 0, 1, 0, 10, 11, 10, 0], + [11, 10, 0, 11, 0, 3, 10, 5, 0, 8, 0, 7, 5, 7, 0], + [11, 10, 5, 7, 11, 5], + [10, 6, 5], + [0, 8, 3, 5, 10, 6], + [9, 0, 1, 5, 10, 6], + [1, 8, 3, 1, 9, 8, 5, 10, 6], + [1, 6, 5, 2, 6, 1], + [1, 6, 5, 1, 2, 6, 3, 0, 8], + [9, 6, 5, 9, 0, 6, 0, 2, 6], + [5, 9, 8, 5, 8, 2, 5, 2, 6, 3, 2, 8], + [2, 3, 11, 10, 6, 5], + [11, 0, 8, 11, 2, 0, 10, 6, 5], + [0, 1, 9, 2, 3, 11, 5, 10, 6], + [5, 10, 6, 1, 9, 2, 9, 11, 2, 9, 8, 11], + [6, 3, 11, 6, 5, 3, 5, 1, 3], + [0, 8, 11, 0, 11, 5, 0, 5, 1, 5, 11, 6], + [3, 11, 6, 0, 3, 6, 0, 6, 5, 0, 5, 9], + [6, 5, 9, 6, 9, 11, 11, 9, 8], + [5, 10, 6, 4, 7, 8], + [4, 3, 0, 4, 7, 3, 6, 5, 10], + [1, 9, 0, 5, 10, 6, 8, 4, 7], + [10, 6, 5, 1, 9, 7, 1, 7, 3, 7, 9, 4], + [6, 1, 2, 6, 5, 1, 4, 7, 8], + [1, 2, 5, 5, 2, 6, 3, 0, 4, 3, 4, 7], + [8, 4, 7, 9, 0, 5, 0, 6, 5, 0, 2, 6], + [7, 3, 9, 7, 9, 4, 3, 2, 9, 5, 9, 6, 2, 6, 9], + [3, 11, 2, 7, 8, 4, 10, 6, 5], + [5, 10, 6, 4, 7, 2, 4, 2, 0, 2, 7, 11], + [0, 1, 9, 4, 7, 8, 2, 3, 11, 5, 10, 6], + [9, 2, 1, 9, 11, 2, 9, 4, 11, 7, 11, 4, 5, 10, 6], + [8, 4, 7, 3, 11, 5, 3, 5, 1, 5, 11, 6], + [5, 1, 11, 5, 11, 6, 1, 0, 11, 7, 11, 4, 0, 4, 11], + [0, 5, 9, 0, 6, 5, 0, 3, 6, 11, 6, 3, 8, 4, 7], + [6, 5, 9, 6, 9, 11, 4, 7, 9, 7, 11, 9], + [10, 4, 9, 6, 4, 10], + [4, 10, 6, 4, 9, 10, 0, 8, 3], + [10, 0, 1, 10, 6, 0, 6, 4, 0], + [8, 3, 1, 8, 1, 6, 8, 6, 4, 6, 1, 10], + [1, 4, 9, 1, 2, 4, 2, 6, 4], + [3, 0, 8, 1, 2, 9, 2, 4, 9, 2, 6, 4], + [0, 2, 4, 4, 2, 6], + [8, 3, 2, 8, 2, 4, 4, 2, 6], + [10, 4, 9, 10, 6, 4, 11, 2, 3], + [0, 8, 2, 2, 8, 11, 4, 9, 10, 4, 10, 6], + [3, 11, 2, 0, 1, 6, 0, 6, 4, 6, 1, 10], + [6, 4, 1, 6, 1, 10, 4, 8, 1, 2, 1, 11, 8, 11, 1], + [9, 6, 4, 9, 3, 6, 9, 1, 3, 11, 6, 3], + [8, 11, 1, 8, 1, 0, 11, 6, 1, 9, 1, 4, 6, 4, 1], + [3, 11, 6, 3, 6, 0, 0, 6, 4], + [6, 4, 8, 11, 6, 8], + [7, 10, 6, 7, 8, 10, 8, 9, 10], + [0, 7, 3, 0, 10, 7, 0, 9, 10, 6, 7, 10], + [10, 6, 7, 1, 10, 7, 1, 7, 8, 1, 8, 0], + [10, 6, 7, 10, 7, 1, 1, 7, 3], + [1, 2, 6, 1, 6, 8, 1, 8, 9, 8, 6, 7], + [2, 6, 9, 2, 9, 1, 6, 7, 9, 0, 9, 3, 7, 3, 9], + [7, 8, 0, 7, 0, 6, 6, 0, 2], + [7, 3, 2, 6, 7, 2], + [2, 3, 11, 10, 6, 8, 10, 8, 9, 8, 6, 7], + [2, 0, 7, 2, 7, 11, 0, 9, 7, 6, 7, 10, 9, 10, 7], + [1, 8, 0, 1, 7, 8, 1, 10, 7, 6, 7, 10, 2, 3, 11], + [11, 2, 1, 11, 1, 7, 10, 6, 1, 6, 7, 1], + [8, 9, 6, 8, 6, 7, 9, 1, 6, 11, 6, 3, 1, 3, 6], + [0, 9, 1, 11, 6, 7], + [7, 8, 0, 7, 0, 6, 3, 11, 0, 11, 6, 0], + [7, 11, 6], + [7, 6, 11], + [3, 0, 8, 11, 7, 6], + [0, 1, 9, 11, 7, 6], + [8, 1, 9, 8, 3, 1, 11, 7, 6], + [10, 1, 2, 6, 11, 7], + [1, 2, 10, 3, 0, 8, 6, 11, 7], + [2, 9, 0, 2, 10, 9, 6, 11, 7], + [6, 11, 7, 2, 10, 3, 10, 8, 3, 10, 9, 8], + [7, 2, 3, 6, 2, 7], + [7, 0, 8, 7, 6, 0, 6, 2, 0], + [2, 7, 6, 2, 3, 7, 0, 1, 9], + [1, 6, 2, 1, 8, 6, 1, 9, 8, 8, 7, 6], + [10, 7, 6, 10, 1, 7, 1, 3, 7], + [10, 7, 6, 1, 7, 10, 1, 8, 7, 1, 0, 8], + [0, 3, 7, 0, 7, 10, 0, 10, 9, 6, 10, 7], + [7, 6, 10, 7, 10, 8, 8, 10, 9], + [6, 8, 4, 11, 8, 6], + [3, 6, 11, 3, 0, 6, 0, 4, 6], + [8, 6, 11, 8, 4, 6, 9, 0, 1], + [9, 4, 6, 9, 6, 3, 9, 3, 1, 11, 3, 6], + [6, 8, 4, 6, 11, 8, 2, 10, 1], + [1, 2, 10, 3, 0, 11, 0, 6, 11, 0, 4, 6], + [4, 11, 8, 4, 6, 11, 0, 2, 9, 2, 10, 9], + [10, 9, 3, 10, 3, 2, 9, 4, 3, 11, 3, 6, 4, 6, 3], + [8, 2, 3, 8, 4, 2, 4, 6, 2], + [0, 4, 2, 4, 6, 2], + [1, 9, 0, 2, 3, 4, 2, 4, 6, 4, 3, 8], + [1, 9, 4, 1, 4, 2, 2, 4, 6], + [8, 1, 3, 8, 6, 1, 8, 4, 6, 6, 10, 1], + [10, 1, 0, 10, 0, 6, 6, 0, 4], + [4, 6, 3, 4, 3, 8, 6, 10, 3, 0, 3, 9, 10, 9, 3], + [10, 9, 4, 6, 10, 4], + [4, 9, 5, 7, 6, 11], + [0, 8, 3, 4, 9, 5, 11, 7, 6], + [5, 0, 1, 5, 4, 0, 7, 6, 11], + [11, 7, 6, 8, 3, 4, 3, 5, 4, 3, 1, 5], + [9, 5, 4, 10, 1, 2, 7, 6, 11], + [6, 11, 7, 1, 2, 10, 0, 8, 3, 4, 9, 5], + [7, 6, 11, 5, 4, 10, 4, 2, 10, 4, 0, 2], + [3, 4, 8, 3, 5, 4, 3, 2, 5, 10, 5, 2, 11, 7, 6], + [7, 2, 3, 7, 6, 2, 5, 4, 9], + [9, 5, 4, 0, 8, 6, 0, 6, 2, 6, 8, 7], + [3, 6, 2, 3, 7, 6, 1, 5, 0, 5, 4, 0], + [6, 2, 8, 6, 8, 7, 2, 1, 8, 4, 8, 5, 1, 5, 8], + [9, 5, 4, 10, 1, 6, 1, 7, 6, 1, 3, 7], + [1, 6, 10, 1, 7, 6, 1, 0, 7, 8, 7, 0, 9, 5, 4], + [4, 0, 10, 4, 10, 5, 0, 3, 10, 6, 10, 7, 3, 7, 10], + [7, 6, 10, 7, 10, 8, 5, 4, 10, 4, 8, 10], + [6, 9, 5, 6, 11, 9, 11, 8, 9], + [3, 6, 11, 0, 6, 3, 0, 5, 6, 0, 9, 5], + [0, 11, 8, 0, 5, 11, 0, 1, 5, 5, 6, 11], + [6, 11, 3, 6, 3, 5, 5, 3, 1], + [1, 2, 10, 9, 5, 11, 9, 11, 8, 11, 5, 6], + [0, 11, 3, 0, 6, 11, 0, 9, 6, 5, 6, 9, 1, 2, 10], + [11, 8, 5, 11, 5, 6, 8, 0, 5, 10, 5, 2, 0, 2, 5], + [6, 11, 3, 6, 3, 5, 2, 10, 3, 10, 5, 3], + [5, 8, 9, 5, 2, 8, 5, 6, 2, 3, 8, 2], + [9, 5, 6, 9, 6, 0, 0, 6, 2], + [1, 5, 8, 1, 8, 0, 5, 6, 8, 3, 8, 2, 6, 2, 8], + [1, 5, 6, 2, 1, 6], + [1, 3, 6, 1, 6, 10, 3, 8, 6, 5, 6, 9, 8, 9, 6], + [10, 1, 0, 10, 0, 6, 9, 5, 0, 5, 6, 0], + [0, 3, 8, 5, 6, 10], + [10, 5, 6], + [11, 5, 10, 7, 5, 11], + [11, 5, 10, 11, 7, 5, 8, 3, 0], + [5, 11, 7, 5, 10, 11, 1, 9, 0], + [10, 7, 5, 10, 11, 7, 9, 8, 1, 8, 3, 1], + [11, 1, 2, 11, 7, 1, 7, 5, 1], + [0, 8, 3, 1, 2, 7, 1, 7, 5, 7, 2, 11], + [9, 7, 5, 9, 2, 7, 9, 0, 2, 2, 11, 7], + [7, 5, 2, 7, 2, 11, 5, 9, 2, 3, 2, 8, 9, 8, 2], + [2, 5, 10, 2, 3, 5, 3, 7, 5], + [8, 2, 0, 8, 5, 2, 8, 7, 5, 10, 2, 5], + [9, 0, 1, 5, 10, 3, 5, 3, 7, 3, 10, 2], + [9, 8, 2, 9, 2, 1, 8, 7, 2, 10, 2, 5, 7, 5, 2], + [1, 3, 5, 3, 7, 5], + [0, 8, 7, 0, 7, 1, 1, 7, 5], + [9, 0, 3, 9, 3, 5, 5, 3, 7], + [9, 8, 7, 5, 9, 7], + [5, 8, 4, 5, 10, 8, 10, 11, 8], + [5, 0, 4, 5, 11, 0, 5, 10, 11, 11, 3, 0], + [0, 1, 9, 8, 4, 10, 8, 10, 11, 10, 4, 5], + [10, 11, 4, 10, 4, 5, 11, 3, 4, 9, 4, 1, 3, 1, 4], + [2, 5, 1, 2, 8, 5, 2, 11, 8, 4, 5, 8], + [0, 4, 11, 0, 11, 3, 4, 5, 11, 2, 11, 1, 5, 1, 11], + [0, 2, 5, 0, 5, 9, 2, 11, 5, 4, 5, 8, 11, 8, 5], + [9, 4, 5, 2, 11, 3], + [2, 5, 10, 3, 5, 2, 3, 4, 5, 3, 8, 4], + [5, 10, 2, 5, 2, 4, 4, 2, 0], + [3, 10, 2, 3, 5, 10, 3, 8, 5, 4, 5, 8, 0, 1, 9], + [5, 10, 2, 5, 2, 4, 1, 9, 2, 9, 4, 2], + [8, 4, 5, 8, 5, 3, 3, 5, 1], + [0, 4, 5, 1, 0, 5], + [8, 4, 5, 8, 5, 3, 9, 0, 5, 0, 3, 5], + [9, 4, 5], + [4, 11, 7, 4, 9, 11, 9, 10, 11], + [0, 8, 3, 4, 9, 7, 9, 11, 7, 9, 10, 11], + [1, 10, 11, 1, 11, 4, 1, 4, 0, 7, 4, 11], + [3, 1, 4, 3, 4, 8, 1, 10, 4, 7, 4, 11, 10, 11, 4], + [4, 11, 7, 9, 11, 4, 9, 2, 11, 9, 1, 2], + [9, 7, 4, 9, 11, 7, 9, 1, 11, 2, 11, 1, 0, 8, 3], + [11, 7, 4, 11, 4, 2, 2, 4, 0], + [11, 7, 4, 11, 4, 2, 8, 3, 4, 3, 2, 4], + [2, 9, 10, 2, 7, 9, 2, 3, 7, 7, 4, 9], + [9, 10, 7, 9, 7, 4, 10, 2, 7, 8, 7, 0, 2, 0, 7], + [3, 7, 10, 3, 10, 2, 7, 4, 10, 1, 10, 0, 4, 0, 10], + [1, 10, 2, 8, 7, 4], + [4, 9, 1, 4, 1, 7, 7, 1, 3], + [4, 9, 1, 4, 1, 7, 0, 8, 1, 8, 7, 1], + [4, 0, 3, 7, 4, 3], + [4, 8, 7], + [9, 10, 8, 10, 11, 8], + [3, 0, 9, 3, 9, 11, 11, 9, 10], + [0, 1, 10, 0, 10, 8, 8, 10, 11], + [3, 1, 10, 11, 3, 10], + [1, 2, 11, 1, 11, 9, 9, 11, 8], + [3, 0, 9, 3, 9, 11, 1, 2, 9, 2, 11, 9], + [0, 2, 11, 8, 0, 11], + [3, 2, 11], + [2, 3, 8, 2, 8, 10, 10, 8, 9], + [9, 10, 2, 0, 9, 2], + [2, 3, 8, 2, 8, 10, 0, 1, 8, 1, 10, 8], + [1, 10, 2], + [1, 3, 8, 9, 1, 8], + [0, 9, 1], + [0, 3, 8], + [], +]; + +export { marchingCubes }; +export type { Mesh, MarchingCubesMesh, MarchingCubesOptions }; diff --git a/packages/splat-transform/src/utils/voxel/mesh.ts b/packages/splat-transform/src/utils/voxel/mesh.ts new file mode 100644 index 0000000..5eabfc0 --- /dev/null +++ b/packages/splat-transform/src/utils/voxel/mesh.ts @@ -0,0 +1,154 @@ +import type { SparseVoxelGrid, Bounds } from './common.js'; +import { coplanarMerge } from './coplanarMerge.js'; +import { marchingCubes, type Mesh } from './marchingCubes.js'; +import { voxelFaces } from './voxelFaces.js'; +import { logger } from '../Logger.js'; + +export type CollisionMeshShape = 'smooth' | 'faces'; + +function encodeGlb(positions: Float32Array, indices: Uint32Array): Uint8Array { + const vertexCount = positions.length / 3; + const indexCount = indices.length; + + let minX = Infinity, + minY = Infinity, + minZ = Infinity; + let maxX = -Infinity, + maxY = -Infinity, + maxZ = -Infinity; + for (let i = 0; i < positions.length; i += 3) { + const x = positions[i]; + const y = positions[i + 1]; + const z = positions[i + 2]; + if (x < minX) { + minX = x; + } + if (y < minY) { + minY = y; + } + if (z < minZ) { + minZ = z; + } + if (x > maxX) { + maxX = x; + } + if (y > maxY) { + maxY = y; + } + if (z > maxZ) { + maxZ = z; + } + } + + const positionsByteLength = positions.byteLength; + const indicesByteLength = indices.byteLength; + const totalBinSize = positionsByteLength + indicesByteLength; + const gltf = { + asset: { version: '2.0', generator: 'splat-transform' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }], + accessors: [ + { + bufferView: 0, + componentType: 5126, + count: vertexCount, + type: 'VEC3', + min: [minX, minY, minZ], + max: [maxX, maxY, maxZ], + }, + { + bufferView: 1, + componentType: 5125, + count: indexCount, + type: 'SCALAR', + }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: positionsByteLength, target: 34962 }, + { buffer: 0, byteOffset: positionsByteLength, byteLength: indicesByteLength, target: 34963 }, + ], + buffers: [{ byteLength: totalBinSize }], + }; + + const jsonBytes = new TextEncoder().encode(JSON.stringify(gltf)); + const jsonPadding = (4 - (jsonBytes.length % 4)) % 4; + const jsonChunkLength = jsonBytes.length + jsonPadding; + const binPadding = (4 - (totalBinSize % 4)) % 4; + const binChunkLength = totalBinSize + binPadding; + const totalLength = 12 + 8 + jsonChunkLength + 8 + binChunkLength; + const buffer = new ArrayBuffer(totalLength); + const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + let offset = 0; + + view.setUint32(offset, 0x46546c67, true); + offset += 4; + view.setUint32(offset, 2, true); + offset += 4; + view.setUint32(offset, totalLength, true); + offset += 4; + view.setUint32(offset, jsonChunkLength, true); + offset += 4; + view.setUint32(offset, 0x4e4f534a, true); + offset += 4; + bytes.set(jsonBytes, offset); + offset += jsonBytes.length; + for (let i = 0; i < jsonPadding; i++) { + bytes[offset++] = 0x20; + } + view.setUint32(offset, binChunkLength, true); + offset += 4; + view.setUint32(offset, 0x004e4942, true); + offset += 4; + bytes.set(new Uint8Array(positions.buffer, positions.byteOffset, positionsByteLength), offset); + offset += positionsByteLength; + bytes.set(new Uint8Array(indices.buffer, indices.byteOffset, indicesByteLength), offset); + + return bytes; +} + +export function buildCollisionMesh( + grid: SparseVoxelGrid, + gridBounds: Bounds, + voxelResolution: number, + shape: CollisionMeshShape = 'smooth', +): Uint8Array | undefined { + const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); + const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); + const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); + if (nx % 4 !== 0 || ny % 4 !== 0 || nz % 4 !== 0) { + return undefined; + } + + logger.time('Build collision mesh'); + let mesh: Mesh; + if (shape === 'faces') { + mesh = voxelFaces(grid, gridBounds, voxelResolution); + logger.info( + `collision mesh faces: ${mesh.positions.length / 3} vertices, ${mesh.indices.length / 3} triangles`, + ); + } else { + const preMerged = marchingCubes(grid, gridBounds, voxelResolution, { mergeFlatFaces: true }); + logger.info( + `collision mesh pre-merged: ${preMerged.positions.length / 3} vertices, ${preMerged.indices.length / 3} triangles`, + ); + if (preMerged.indices.length < 3) { + mesh = preMerged; + } else { + mesh = coplanarMerge(preMerged, voxelResolution); + const reduction = (1 - mesh.indices.length / preMerged.indices.length) * 100; + logger.info( + `collision mesh merged: ${mesh.positions.length / 3} vertices, ${mesh.indices.length / 3} triangles`, + ); + logger.info(`collision mesh reduction: ${reduction.toFixed(0)}%`); + } + } + logger.timeEnd('Build collision mesh'); + if (mesh.indices.length < 3) { + logger.warn('collision mesh: no triangles generated, skipping GLB output'); + return undefined; + } + return encodeGlb(mesh.positions, mesh.indices); +} diff --git a/packages/splat-transform/src/utils/voxel/nav.ts b/packages/splat-transform/src/utils/voxel/nav.ts new file mode 100644 index 0000000..164312b --- /dev/null +++ b/packages/splat-transform/src/utils/voxel/nav.ts @@ -0,0 +1,1167 @@ +import { + BLOCK_EMPTY, + BLOCK_SOLID, + BLOCK_MIXED, + SOLID_LO, + SOLID_HI, + SparseVoxelGrid, + readBlockType, + writeBlockType, + type Bounds, +} from './common.js'; +import { gpuDilate3 } from './gpuDilation.js'; +import { logger } from '../Logger.js'; + +interface NavSeed { + x: number; + y: number; + z: number; +} +type VoxelBackend = 'cpu' | 'gpu'; + +const FACE_MASKS_LO = [ + 0x11111111 >>> 0, // -X + 0x88888888 >>> 0, // +X + 0x000f000f >>> 0, // -Y + 0xf000f000 >>> 0, // +Y + 0x0000ffff >>> 0, // -Z + 0x00000000 >>> 0, // +Z +]; +const FACE_MASKS_HI = [ + 0x11111111 >>> 0, + 0x88888888 >>> 0, + 0x000f000f >>> 0, + 0xf000f000 >>> 0, + 0x00000000 >>> 0, + 0xffff0000 >>> 0, +]; + +function forEachNonEmptyBlock(grid: SparseVoxelGrid, fn: (blockIdx: number) => void): void { + const totalBlocks = grid.nbx * grid.nby * grid.nbz; + for (let w = 0; w < grid.types.length; w++) { + let nonEmpty = ((grid.types[w] & 0x55555555) | ((grid.types[w] >>> 1) & 0x55555555)) >>> 0; + const baseIdx = w * 16; + while (nonEmpty) { + const bitPos = 31 - Math.clz32(nonEmpty & -nonEmpty); + const blockIdx = baseIdx + (bitPos >>> 1); + if (blockIdx >= totalBlocks) { + break; + } + fn(blockIdx); + nonEmpty &= nonEmpty - 1; + } + } +} + +// Active block-pair extraction for separable dilation passes. +function getActiveYZPairs(grid: SparseVoxelGrid): Set { + const pairs = new Set(); + const { nbx } = grid; + forEachNonEmptyBlock(grid, blockIdx => pairs.add((blockIdx / nbx) | 0)); + return pairs; +} + +function getActiveXZPairs(grid: SparseVoxelGrid): Set { + const pairs = new Set(); + const { nbx, bStride } = grid; + forEachNonEmptyBlock(grid, blockIdx => { + const bx = blockIdx % nbx; + const bz = (blockIdx / bStride) | 0; + pairs.add(bx + bz * nbx); + }); + return pairs; +} + +function getActiveXYPairs(grid: SparseVoxelGrid): Set { + const pairs = new Set(); + const { nbx, nby } = grid; + forEachNonEmptyBlock(grid, blockIdx => { + const bx = blockIdx % nbx; + const by = ((blockIdx / nbx) | 0) % nby; + pairs.add(bx + by * nbx); + }); + return pairs; +} + +// Line extraction/writeback helpers between sparse block masks and bit-packed 1D buffers. +function extractLineX(grid: SparseVoxelGrid, iy: number, iz: number, buf: Uint32Array): void { + const by = iy >> 2, + bz = iz >> 2; + const bitBase = ((iz & 3) << 4) + ((iy & 3) << 2); + const inHi = bitBase >= 32; + const shift = inHi ? bitBase - 32 : bitBase; + const lineBase = by * grid.nbx + bz * grid.bStride; + for (let bx = 0; bx < grid.nbx; bx++) { + const blockIdx = lineBase + bx; + const bt = readBlockType(grid.types, blockIdx); + if (bt === BLOCK_EMPTY) { + continue; + } + let row4: number; + if (bt === BLOCK_SOLID) { + row4 = 0xf; + } else { + const s = grid.masks.slot(blockIdx); + row4 = ((inHi ? grid.masks.hi[s] : grid.masks.lo[s]) >>> shift) & 0xf; + } + if (row4) { + const ix = bx << 2; + buf[ix >>> 5] |= row4 << (ix & 31); + } + } +} +function writeLineX(grid: SparseVoxelGrid, iy: number, iz: number, buf: Uint32Array): void { + const by = iy >> 2, + bz = iz >> 2; + const bitBase = ((iz & 3) << 4) + ((iy & 3) << 2); + const inHi = bitBase >= 32; + const shift = inHi ? bitBase - 32 : bitBase; + const lineBase = by * grid.nbx + bz * grid.bStride; + for (let bx = 0; bx < grid.nbx; bx++) { + const ix = bx << 2; + const row4 = (buf[ix >>> 5] >>> (ix & 31)) & 0xf; + if (!row4) { + continue; + } + const blockIdx = lineBase + bx; + grid.orBlock(blockIdx, inHi ? 0 : (row4 << shift) >>> 0, inHi ? (row4 << shift) >>> 0 : 0); + } +} +function extractLineY(grid: SparseVoxelGrid, ix: number, iz: number, buf: Uint32Array): void { + const bx = ix >> 2, + bz = iz >> 2; + const lx = ix & 3, + lz = iz & 3; + const inHi = lz >= 2; + const base = lx + (lz & 1) * 16; + for (let by = 0; by < grid.nby; by++) { + const blockIdx = bx + by * grid.nbx + bz * grid.bStride; + const bt = readBlockType(grid.types, blockIdx); + if (bt === BLOCK_EMPTY) { + continue; + } + let row4: number; + if (bt === BLOCK_SOLID) { + row4 = 0xf; + } else { + const s = grid.masks.slot(blockIdx); + const word = inHi ? grid.masks.hi[s] : grid.masks.lo[s]; + row4 = + ((word >>> base) & 1) | + (((word >>> (base + 4)) & 1) << 1) | + (((word >>> (base + 8)) & 1) << 2) | + (((word >>> (base + 12)) & 1) << 3); + } + if (row4) { + const iy = by << 2; + buf[iy >>> 5] |= row4 << (iy & 31); + } + } +} +function writeLineY(grid: SparseVoxelGrid, ix: number, iz: number, buf: Uint32Array): void { + const bx = ix >> 2, + bz = iz >> 2; + const lx = ix & 3, + lz = iz & 3; + const inHi = lz >= 2; + const base = lx + (lz & 1) * 16; + for (let by = 0; by < grid.nby; by++) { + const iy = by << 2; + const row4 = (buf[iy >>> 5] >>> (iy & 31)) & 0xf; + if (!row4) { + continue; + } + const blockIdx = bx + by * grid.nbx + bz * grid.bStride; + const bits = + ((row4 & 1) << base) | + (((row4 >>> 1) & 1) << (base + 4)) | + (((row4 >>> 2) & 1) << (base + 8)) | + (((row4 >>> 3) & 1) << (base + 12)); + grid.orBlock(blockIdx, inHi ? 0 : bits >>> 0, inHi ? bits >>> 0 : 0); + } +} +function extractLineZ(grid: SparseVoxelGrid, ix: number, iy: number, buf: Uint32Array): void { + const bx = ix >> 2, + by = iy >> 2; + const base = (ix & 3) + ((iy & 3) << 2); + for (let bz = 0; bz < grid.nbz; bz++) { + const blockIdx = bx + by * grid.nbx + bz * grid.bStride; + const bt = readBlockType(grid.types, blockIdx); + if (bt === BLOCK_EMPTY) { + continue; + } + let row4: number; + if (bt === BLOCK_SOLID) { + row4 = 0xf; + } else { + const s = grid.masks.slot(blockIdx); + row4 = + ((grid.masks.lo[s] >>> base) & 1) | + (((grid.masks.lo[s] >>> (base + 16)) & 1) << 1) | + (((grid.masks.hi[s] >>> base) & 1) << 2) | + (((grid.masks.hi[s] >>> (base + 16)) & 1) << 3); + } + if (row4) { + const iz = bz << 2; + buf[iz >>> 5] |= row4 << (iz & 31); + } + } +} +function writeLineZ(grid: SparseVoxelGrid, ix: number, iy: number, buf: Uint32Array): void { + const bx = ix >> 2, + by = iy >> 2; + const base = (ix & 3) + ((iy & 3) << 2); + for (let bz = 0; bz < grid.nbz; bz++) { + const iz = bz << 2; + const row4 = (buf[iz >>> 5] >>> (iz & 31)) & 0xf; + if (!row4) { + continue; + } + const blockIdx = bx + by * grid.nbx + bz * grid.bStride; + let lo = 0, + hi = 0; + if (row4 & 1) { + lo |= 1 << base; + } + if (row4 & 2) { + lo |= 1 << (base + 16); + } + if (row4 & 4) { + hi |= 1 << base; + } + if (row4 & 8) { + hi |= 1 << (base + 16); + } + grid.orBlock(blockIdx, lo >>> 0, hi >>> 0); + } +} +/** + * 1D binary dilation with a flat window using a sliding count. + * A destination bit is set if any source bit is set within +/- halfExtent. + */ +function flatDilate1D(src: Uint32Array, dst: Uint32Array, n: number, halfExtent: number): void { + let count = 0; + const winEnd = Math.min(halfExtent, n - 1); + for (let i = 0; i <= winEnd; i++) { + if ((src[i >>> 5] >>> (i & 31)) & 1) { + count++; + } + } + for (let i = 0; i < n; i++) { + if (count > 0) { + dst[i >>> 5] |= 1 << (i & 31); + } + const exitI = i - halfExtent; + if (exitI >= 0 && (src[exitI >>> 5] >>> (exitI & 31)) & 1) { + count--; + } + const enterI = i + halfExtent + 1; + if (enterI < n && (src[enterI >>> 5] >>> (enterI & 31)) & 1) { + count++; + } + } +} +/** + * Dilate along X by extracting X-lines from sparse blocks, dilating each line, + * then writing back into destination blocks. + */ +function sparseDilateX(src: SparseVoxelGrid, dst: SparseVoxelGrid, halfExtent: number): void { + const { nx, ny, nz, nbx, nby, bStride } = src; + const lineWords = (nx + 31) >>> 5; + const srcBuf = new Uint32Array(lineWords); + const dstBuf = new Uint32Array(lineWords); + const activePairs = getActiveYZPairs(src); + for (const key of activePairs) { + const by = key % nby; + const bz = (key / nby) | 0; + const lineBase = by * nbx + bz * bStride; + let allSolid = true; + for (let bx = 0; bx < nbx; bx++) { + if (readBlockType(src.types, lineBase + bx) !== BLOCK_SOLID) { + allSolid = false; + break; + } + } + if (allSolid) { + for (let bx = 0; bx < nbx; bx++) { + dst.orBlock(lineBase + bx, SOLID_LO, SOLID_HI); + } + continue; + } + for (let ly = 0; ly < 4; ly++) { + const iy = (by << 2) + ly; + if (iy >= ny) { + continue; + } + for (let lz = 0; lz < 4; lz++) { + const iz = (bz << 2) + lz; + if (iz >= nz) { + continue; + } + srcBuf.fill(0); + dstBuf.fill(0); + extractLineX(src, iy, iz, srcBuf); + flatDilate1D(srcBuf, dstBuf, nx, halfExtent); + writeLineX(dst, iy, iz, dstBuf); + } + } + } +} +/** + * Dilate along Y by extracting Y-lines from sparse blocks. + */ +function sparseDilateY(src: SparseVoxelGrid, dst: SparseVoxelGrid, halfExtent: number): void { + const { nx, ny, nz, nbx, nby, bStride } = src; + const lineWords = (ny + 31) >>> 5; + const srcBuf = new Uint32Array(lineWords); + const dstBuf = new Uint32Array(lineWords); + const activePairs = getActiveXZPairs(src); + for (const key of activePairs) { + const bx = key % nbx; + const bz = (key / nbx) | 0; + const lineStart = bx + bz * bStride; + let allSolid = true; + for (let by = 0; by < nby; by++) { + if (readBlockType(src.types, lineStart + by * nbx) !== BLOCK_SOLID) { + allSolid = false; + break; + } + } + if (allSolid) { + for (let by = 0; by < nby; by++) { + dst.orBlock(lineStart + by * nbx, SOLID_LO, SOLID_HI); + } + continue; + } + for (let lx = 0; lx < 4; lx++) { + const ix = (bx << 2) + lx; + if (ix >= nx) { + continue; + } + for (let lz = 0; lz < 4; lz++) { + const iz = (bz << 2) + lz; + if (iz >= nz) { + continue; + } + srcBuf.fill(0); + dstBuf.fill(0); + extractLineY(src, ix, iz, srcBuf); + flatDilate1D(srcBuf, dstBuf, ny, halfExtent); + writeLineY(dst, ix, iz, dstBuf); + } + } + } +} +/** + * Dilate along Z by extracting Z-lines from sparse blocks. + */ +function sparseDilateZ(src: SparseVoxelGrid, dst: SparseVoxelGrid, halfExtent: number): void { + const { nx, ny, nz, nbx, nbz, bStride } = src; + const lineWords = (nz + 31) >>> 5; + const srcBuf = new Uint32Array(lineWords); + const dstBuf = new Uint32Array(lineWords); + const activePairs = getActiveXYPairs(src); + for (const key of activePairs) { + const bx = key % nbx; + const by = (key / nbx) | 0; + const lineStart = bx + by * nbx; + let allSolid = true; + for (let bz = 0; bz < nbz; bz++) { + if (readBlockType(src.types, lineStart + bz * bStride) !== BLOCK_SOLID) { + allSolid = false; + break; + } + } + if (allSolid) { + for (let bz = 0; bz < nbz; bz++) { + dst.orBlock(lineStart + bz * bStride, SOLID_LO, SOLID_HI); + } + continue; + } + for (let lx = 0; lx < 4; lx++) { + const ix = (bx << 2) + lx; + if (ix >= nx) { + continue; + } + for (let ly = 0; ly < 4; ly++) { + const iy = (by << 2) + ly; + if (iy >= ny) { + continue; + } + srcBuf.fill(0); + dstBuf.fill(0); + extractLineZ(src, ix, iy, srcBuf); + flatDilate1D(srcBuf, dstBuf, nz, halfExtent); + writeLineZ(dst, ix, iy, dstBuf); + } + } + } +} +/** + * Separable 3D dilation: X pass, then Z pass, then Y pass. + * X/Z share radius while Y can use a different half extent. + */ +function sparseDilate3(src: SparseVoxelGrid, halfExtentXZ: number, halfExtentY: number): SparseVoxelGrid { + const a = new SparseVoxelGrid(src.nx, src.ny, src.nz); + sparseDilateX(src, a, halfExtentXZ); + const b = new SparseVoxelGrid(src.nx, src.ny, src.nz); + sparseDilateZ(a, b, halfExtentXZ); + a.clear(); + sparseDilateY(b, a, halfExtentY); + b.clear(); + return a; +} + +async function dilate3( + src: SparseVoxelGrid, + halfExtentXZ: number, + halfExtentY: number, + backend: VoxelBackend, +): Promise { + return backend === 'gpu' + ? gpuDilate3(src, halfExtentXZ, halfExtentY) + : sparseDilate3(src, halfExtentXZ, halfExtentY); +} + +/** + * Compute reachable empty voxels as visited \ blocked. + * This keeps only flood-filled cells that are not blocked after dilation. + */ +function computeEmptyGrid(visited: SparseVoxelGrid, blocked: SparseVoxelGrid): SparseVoxelGrid { + const empty = new SparseVoxelGrid(visited.nx, visited.ny, visited.nz); + forEachNonEmptyBlock(visited, blockIdx => { + const vbt = readBlockType(visited.types, blockIdx); + let vLo: number, vHi: number; + if (vbt === BLOCK_SOLID) { + vLo = SOLID_LO; + vHi = SOLID_HI; + } else { + const vs = visited.masks.slot(blockIdx); + vLo = visited.masks.lo[vs]; + vHi = visited.masks.hi[vs]; + } + const bbt = readBlockType(blocked.types, blockIdx); + let lo: number, hi: number; + if (bbt === BLOCK_EMPTY) { + lo = vLo; + hi = vHi; + } else if (bbt === BLOCK_SOLID) { + lo = 0; + hi = 0; + } else { + const bs = blocked.masks.slot(blockIdx); + lo = (vLo & ~blocked.masks.lo[bs]) >>> 0; + hi = (vHi & ~blocked.masks.hi[bs]) >>> 0; + } + if (lo || hi) { + empty.orBlock(blockIdx, lo, hi); + } + }); + return empty; +} + +/** + * Sparse OR between two voxel grids (block masks are OR-combined). + */ +function sparseOrGrids(a: SparseVoxelGrid, b: SparseVoxelGrid, consumeA = false): SparseVoxelGrid { + const result = consumeA ? a : a.clone(); + forEachNonEmptyBlock(b, blockIdx => { + const bt = readBlockType(b.types, blockIdx); + if (bt === BLOCK_SOLID) { + result.orBlock(blockIdx, SOLID_LO, SOLID_HI); + } else { + const s = b.masks.slot(blockIdx); + result.orBlock(blockIdx, b.masks.lo[s], b.masks.hi[s]); + } + }); + return result; +} + +/** + * Flood fill on sparse voxel grids using two coupled queues: + * - block queue for fully empty blocks + * - voxel queue for mixed blocks + * This mirrors the reference two-level BFS for performance on sparse data. + */ +function twoLevelBFS( + blocked: SparseVoxelGrid, + blockSeeds: number[], + voxelSeeds: Array<{ ix: number; iy: number; iz: number }>, + nx: number, + ny: number, + nz: number, +): SparseVoxelGrid { + const visited = new SparseVoxelGrid(nx, ny, nz); + const nbx = nx >> 2; + const nby = ny >> 2; + const nbz = nz >> 2; + const bStride = nbx * nby; + const bMasks = blocked.masks; + const vMasks = visited.masks; + + let bqCap = 1 << 14; + let bqBuf = new Uint32Array(bqCap); + let bqMask = bqCap - 1, + bqHead = 0, + bqTail = 0, + bqSize = 0; + + let vqCap = 1 << 14; + let vqIx = new Uint32Array(vqCap); + let vqIy = new Uint32Array(vqCap); + let vqIz = new Uint32Array(vqCap); + let vqMask = vqCap - 1, + vqHead = 0, + vqTail = 0, + vqSize = 0; + + function growBlockQueue(): void { + const newCap = bqCap << 1; + const nb = new Uint32Array(newCap); + for (let i = 0; i < bqSize; i++) { + nb[i] = bqBuf[(bqHead + i) & bqMask]; + } + bqBuf = nb; + bqCap = newCap; + bqMask = newCap - 1; + bqHead = 0; + bqTail = bqSize; + } + function growVoxelQueue(): void { + const newCap = vqCap << 1; + const nix = new Uint32Array(newCap); + const niy = new Uint32Array(newCap); + const niz = new Uint32Array(newCap); + for (let i = 0; i < vqSize; i++) { + const j = (vqHead + i) & vqMask; + nix[i] = vqIx[j]; + niy[i] = vqIy[j]; + niz[i] = vqIz[j]; + } + vqIx = nix; + vqIy = niy; + vqIz = niz; + vqCap = newCap; + vqMask = newCap - 1; + vqHead = 0; + vqTail = vqSize; + } + function enqueueVoxel(ix: number, iy: number, iz: number): void { + if (vqSize >= vqCap) { + growVoxelQueue(); + } + vqIx[vqTail] = ix; + vqIy[vqTail] = iy; + vqIz[vqTail] = iz; + vqTail = (vqTail + 1) & vqMask; + vqSize++; + } + function tryFillBlock(blockIdx: number): boolean { + if (readBlockType(blocked.types, blockIdx) !== BLOCK_EMPTY) { + return false; + } + if (readBlockType(visited.types, blockIdx) !== BLOCK_EMPTY) { + return false; + } + writeBlockType(visited.types, blockIdx, BLOCK_SOLID); + if (bqSize >= bqCap) { + growBlockQueue(); + } + bqBuf[bqTail] = blockIdx; + bqTail = (bqTail + 1) & bqMask; + bqSize++; + return true; + } + function enqueueFaceVoxels(nBlockIdx: number, face: number, nBx: number, nBy: number, nBz: number): void { + const vbt = readBlockType(visited.types, nBlockIdx); + if (vbt === BLOCK_SOLID) { + return; + } + const bs = bMasks.slot(nBlockIdx); + let vLo = 0, + vHi = 0, + vs = -1; + if (vbt === BLOCK_MIXED) { + vs = vMasks.slot(nBlockIdx); + vLo = vMasks.lo[vs]; + vHi = vMasks.hi[vs]; + } + const freeLo = (FACE_MASKS_LO[face] & ~bMasks.lo[bs] & ~vLo) >>> 0; + const freeHi = (FACE_MASKS_HI[face] & ~bMasks.hi[bs] & ~vHi) >>> 0; + if (freeLo === 0 && freeHi === 0) { + return; + } + if (vbt === BLOCK_EMPTY) { + writeBlockType(visited.types, nBlockIdx, BLOCK_MIXED); + vMasks.set(nBlockIdx, freeLo, freeHi); + } else { + vMasks.lo[vs] = (vMasks.lo[vs] | freeLo) >>> 0; + vMasks.hi[vs] = (vMasks.hi[vs] | freeHi) >>> 0; + if (vMasks.lo[vs] === SOLID_LO && vMasks.hi[vs] === SOLID_HI) { + vMasks.removeAt(vs); + writeBlockType(visited.types, nBlockIdx, BLOCK_SOLID); + } + } + const baseIx = nBx << 2, + baseIy = nBy << 2, + baseIz = nBz << 2; + let bits = freeLo; + while (bits) { + const bp = 31 - Math.clz32(bits & -bits); + enqueueVoxel(baseIx + (bp & 3), baseIy + ((bp >> 2) & 3), baseIz + (bp >> 4)); + bits &= bits - 1; + } + bits = freeHi; + while (bits) { + const bp = 31 - Math.clz32(bits & -bits); + const bi = bp + 32; + enqueueVoxel(baseIx + (bi & 3), baseIy + ((bi >> 2) & 3), baseIz + (bi >> 4)); + bits &= bits - 1; + } + } + function processBlock(blockIdx: number): void { + const bx = blockIdx % nbx; + const byBz = (blockIdx / nbx) | 0; + const by = byBz % nby; + const bz = (byBz / nby) | 0; + if (bx > 0) { + const ni = blockIdx - 1; + const nbt = readBlockType(blocked.types, ni); + if (nbt === BLOCK_EMPTY) { + tryFillBlock(ni); + } else if (nbt === BLOCK_MIXED) { + enqueueFaceVoxels(ni, 1, bx - 1, by, bz); + } + } + if (bx < nbx - 1) { + const ni = blockIdx + 1; + const nbt = readBlockType(blocked.types, ni); + if (nbt === BLOCK_EMPTY) { + tryFillBlock(ni); + } else if (nbt === BLOCK_MIXED) { + enqueueFaceVoxels(ni, 0, bx + 1, by, bz); + } + } + if (by > 0) { + const ni = blockIdx - nbx; + const nbt = readBlockType(blocked.types, ni); + if (nbt === BLOCK_EMPTY) { + tryFillBlock(ni); + } else if (nbt === BLOCK_MIXED) { + enqueueFaceVoxels(ni, 3, bx, by - 1, bz); + } + } + if (by < nby - 1) { + const ni = blockIdx + nbx; + const nbt = readBlockType(blocked.types, ni); + if (nbt === BLOCK_EMPTY) { + tryFillBlock(ni); + } else if (nbt === BLOCK_MIXED) { + enqueueFaceVoxels(ni, 2, bx, by + 1, bz); + } + } + if (bz > 0) { + const ni = blockIdx - bStride; + const nbt = readBlockType(blocked.types, ni); + if (nbt === BLOCK_EMPTY) { + tryFillBlock(ni); + } else if (nbt === BLOCK_MIXED) { + enqueueFaceVoxels(ni, 5, bx, by, bz - 1); + } + } + if (bz < nbz - 1) { + const ni = blockIdx + bStride; + const nbt = readBlockType(blocked.types, ni); + if (nbt === BLOCK_EMPTY) { + tryFillBlock(ni); + } else if (nbt === BLOCK_MIXED) { + enqueueFaceVoxels(ni, 4, bx, by, bz + 1); + } + } + } + function tryEnqueueVoxel(ix: number, iy: number, iz: number): void { + const blockIdx = (ix >> 2) + (iy >> 2) * nbx + (iz >> 2) * bStride; + const bbt = readBlockType(blocked.types, blockIdx); + if (bbt === BLOCK_SOLID) { + return; + } + if (bbt === BLOCK_EMPTY) { + tryFillBlock(blockIdx); + return; + } + const bs = bMasks.slot(blockIdx); + const bitIdx = (ix & 3) + ((iy & 3) << 2) + ((iz & 3) << 4); + if (bitIdx < 32 ? (bMasks.lo[bs] >>> bitIdx) & 1 : (bMasks.hi[bs] >>> (bitIdx - 32)) & 1) { + return; + } + const vbt = readBlockType(visited.types, blockIdx); + if (vbt === BLOCK_SOLID) { + return; + } + if (vbt === BLOCK_MIXED) { + const vs = vMasks.slot(blockIdx); + if (bitIdx < 32 ? (vMasks.lo[vs] >>> bitIdx) & 1 : (vMasks.hi[vs] >>> (bitIdx - 32)) & 1) { + return; + } + if (bitIdx < 32) { + vMasks.lo[vs] = (vMasks.lo[vs] | (1 << bitIdx)) >>> 0; + } else { + vMasks.hi[vs] = (vMasks.hi[vs] | (1 << (bitIdx - 32))) >>> 0; + } + if (vMasks.lo[vs] === SOLID_LO && vMasks.hi[vs] === SOLID_HI) { + vMasks.removeAt(vs); + writeBlockType(visited.types, blockIdx, BLOCK_SOLID); + } + } else { + writeBlockType(visited.types, blockIdx, BLOCK_MIXED); + vMasks.set(blockIdx, bitIdx < 32 ? (1 << bitIdx) >>> 0 : 0, bitIdx >= 32 ? (1 << (bitIdx - 32)) >>> 0 : 0); + } + enqueueVoxel(ix, iy, iz); + } + for (let i = 0; i < blockSeeds.length; i++) { + tryFillBlock(blockSeeds[i]); + } + for (let i = 0; i < voxelSeeds.length; i++) { + const s = voxelSeeds[i]; + tryEnqueueVoxel(s.ix, s.iy, s.iz); + } + while (bqSize > 0 || vqSize > 0) { + while (bqSize > 0) { + const blockIdx = bqBuf[bqHead]; + bqHead = (bqHead + 1) & bqMask; + bqSize--; + processBlock(blockIdx); + } + if (vqSize > 0) { + const ix = vqIx[vqHead], + iy = vqIy[vqHead], + iz = vqIz[vqHead]; + vqHead = (vqHead + 1) & vqMask; + vqSize--; + if (ix > 0) { + tryEnqueueVoxel(ix - 1, iy, iz); + } + if (ix < nx - 1) { + tryEnqueueVoxel(ix + 1, iy, iz); + } + if (iy > 0) { + tryEnqueueVoxel(ix, iy - 1, iz); + } + if (iy < ny - 1) { + tryEnqueueVoxel(ix, iy + 1, iz); + } + if (iz > 0) { + tryEnqueueVoxel(ix, iy, iz - 1); + } + if (iz < nz - 1) { + tryEnqueueVoxel(ix, iy, iz + 1); + } + } + } + return visited; +} + +function cloneBounds(b: Bounds): Bounds { + return { min: { ...b.min }, max: { ...b.max } }; +} + +interface VoxelNavResult { + grid: SparseVoxelGrid; + gridBounds: Bounds; +} + +/** + * Fill exterior-reachable space from boundary seeds and merge it back into + * occupancy after dilation. Returns cropped bounds around navigable volume. + */ +export async function fillExterior( + gridOriginal: SparseVoxelGrid, + gridBounds: Bounds, + voxelResolution: number, + dilation: number, + seed: NavSeed, + backend: VoxelBackend = 'cpu', +): Promise { + if (!Number.isFinite(voxelResolution) || voxelResolution <= 0) { + throw new Error(`fillExterior: voxelResolution must be finite and > 0, got ${voxelResolution}`); + } + if (!Number.isFinite(dilation) || dilation <= 0) { + throw new Error(`fillExterior: dilation must be finite and > 0, got ${dilation}`); + } + const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); + const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); + const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); + if (nx % 4 !== 0 || ny % 4 !== 0 || nz % 4 !== 0) { + throw new Error(`fillExterior: grid dimensions must be multiples of 4, got ${nx}x${ny}x${nz}`); + } + const halfExtent = Math.ceil(dilation / voxelResolution); + const nbx = nx >> 2, + nby = ny >> 2, + nbz = nz >> 2; + const dilated = await dilate3(gridOriginal, halfExtent, halfExtent, backend); + const bStride = nbx * nby; + const blockSeeds: number[] = []; + const faceVoxelSeeds: Array<{ ix: number; iy: number; iz: number }> = []; + function seedBoundaryBlock(blockIdx: number, bx: number, by: number, bz: number, face: number): void { + const bt = readBlockType(dilated.types, blockIdx); + if (bt === BLOCK_SOLID) { + return; + } + if (bt === BLOCK_EMPTY) { + blockSeeds.push(blockIdx); + return; + } + const ms = dilated.masks.slot(blockIdx); + let freeLo = (FACE_MASKS_LO[face] & ~dilated.masks.lo[ms]) >>> 0; + let freeHi = (FACE_MASKS_HI[face] & ~dilated.masks.hi[ms]) >>> 0; + if (freeLo === 0 && freeHi === 0) { + return; + } + const baseIx = bx << 2, + baseIy = by << 2, + baseIz = bz << 2; + while (freeLo) { + const bp = 31 - Math.clz32(freeLo & -freeLo); + faceVoxelSeeds.push({ ix: baseIx + (bp & 3), iy: baseIy + ((bp >> 2) & 3), iz: baseIz + (bp >> 4) }); + freeLo &= freeLo - 1; + } + while (freeHi) { + const bp = 31 - Math.clz32(freeHi & -freeHi); + const bi = bp + 32; + faceVoxelSeeds.push({ ix: baseIx + (bi & 3), iy: baseIy + ((bi >> 2) & 3), iz: baseIz + (bi >> 4) }); + freeHi &= freeHi - 1; + } + } + for (let bz = 0; bz < nbz; bz++) { + for (let by = 0; by < nby; by++) { + seedBoundaryBlock(by * nbx + bz * bStride, 0, by, bz, 0); + } + } + for (let bz = 0; bz < nbz; bz++) { + for (let by = 0; by < nby; by++) { + seedBoundaryBlock(nbx - 1 + by * nbx + bz * bStride, nbx - 1, by, bz, 1); + } + } + for (let bz = 0; bz < nbz; bz++) { + for (let bx = 0; bx < nbx; bx++) { + seedBoundaryBlock(bx + bz * bStride, bx, 0, bz, 2); + } + } + for (let bz = 0; bz < nbz; bz++) { + for (let bx = 0; bx < nbx; bx++) { + seedBoundaryBlock(bx + (nby - 1) * nbx + bz * bStride, bx, nby - 1, bz, 3); + } + } + for (let by = 0; by < nby; by++) { + for (let bx = 0; bx < nbx; bx++) { + seedBoundaryBlock(bx + by * nbx, bx, by, 0, 4); + } + } + for (let by = 0; by < nby; by++) { + for (let bx = 0; bx < nbx; bx++) { + seedBoundaryBlock(bx + by * nbx + (nbz - 1) * bStride, bx, by, nbz - 1, 5); + } + } + + const visited = twoLevelBFS(dilated, blockSeeds, faceVoxelSeeds, nx, ny, nz); + const seedIx = Math.floor((seed.x - gridBounds.min.x) / voxelResolution); + const seedIy = Math.floor((seed.y - gridBounds.min.y) / voxelResolution); + const seedIz = Math.floor((seed.z - gridBounds.min.z) / voxelResolution); + if (seedIx >= 0 && seedIx < nx && seedIy >= 0 && seedIy < ny && seedIz >= 0 && seedIz < nz) { + if (visited.getVoxel(seedIx, seedIy, seedIz)) { + logger.info('fillExteriorMap: seed reachable from outside, skipping'); + return { grid: gridOriginal, gridBounds }; + } + } else { + logger.info('fillExteriorMap: seed outside grid bounds, skipping exterior fill'); + return { grid: gridOriginal, gridBounds }; + } + const dilatedVisited = await dilate3(visited, halfExtent, halfExtent, backend); + const combined = sparseOrGrids(gridOriginal, dilatedVisited); + + let minIx = nx, + minIy = ny, + minIz = nz; + let maxIx = 0, + maxIy = 0, + maxIz = 0; + for (let bz = 0; bz < nbz; bz++) { + for (let by = 0; by < nby; by++) { + for (let bx = 0; bx < nbx; bx++) { + const blockIdx = bx + by * nbx + bz * combined.bStride; + const bt = readBlockType(combined.types, blockIdx); + if (bt === BLOCK_SOLID) { + continue; + } + if (bt === BLOCK_MIXED) { + const cs = combined.masks.slot(blockIdx); + if (combined.masks.lo[cs] === SOLID_LO && combined.masks.hi[cs] === SOLID_HI) { + continue; + } + } + const baseX = bx << 2, + baseY = by << 2, + baseZ = bz << 2; + if (baseX < minIx) { + minIx = baseX; + } + if (baseX + 3 > maxIx) { + maxIx = baseX + 3; + } + if (baseY < minIy) { + minIy = baseY; + } + if (baseY + 3 > maxIy) { + maxIy = baseY + 3; + } + if (baseZ < minIz) { + minIz = baseZ; + } + if (baseZ + 3 > maxIz) { + maxIz = baseZ + 3; + } + } + } + } + if (minIx > maxIx) { + logger.warn('fillExteriorMap: no navigable cells remain, returning empty result'); + return { + grid: new SparseVoxelGrid(4, 4, 4), + gridBounds: { min: { ...gridBounds.min }, max: { ...gridBounds.min } }, + }; + } + const MARGIN = 1; + const cropMinBx = Math.max(0, (minIx >> 2) - MARGIN); + const cropMinBy = Math.max(0, (minIy >> 2) - MARGIN); + const cropMinBz = Math.max(0, (minIz >> 2) - MARGIN); + const cropMaxBx = Math.min(nbx, (maxIx >> 2) + 1 + MARGIN); + const cropMaxBy = Math.min(nby, (maxIy >> 2) + 1 + MARGIN); + const cropMaxBz = Math.min(nbz, (maxIz >> 2) + 1 + MARGIN); + const blockSize = 4 * voxelResolution; + const croppedMin = { + x: gridBounds.min.x + cropMinBx * blockSize, + y: gridBounds.min.y + cropMinBy * blockSize, + z: gridBounds.min.z + cropMinBz * blockSize, + }; + const croppedBounds: Bounds = { + min: croppedMin, + max: { + x: croppedMin.x + (cropMaxBx - cropMinBx) * blockSize, + y: croppedMin.y + (cropMaxBy - cropMinBy) * blockSize, + z: croppedMin.z + (cropMaxBz - cropMinBz) * blockSize, + }, + }; + return { + grid: combined.cropTo(cropMinBx, cropMinBy, cropMinBz, cropMaxBx, cropMaxBy, cropMaxBz), + gridBounds: croppedBounds, + }; +} + +/** + * Carve navigable space for a capsule by: + * 1) dilating blocked voxels by capsule dimensions + * 2) flood filling reachable empty space from the seed + * 3) dilating and inverting to final occupancy representation. + */ +export async function carve( + grid: SparseVoxelGrid, + gridBounds: Bounds, + voxelResolution: number, + capsuleHeight: number, + capsuleRadius: number, + seed: NavSeed, + backend: VoxelBackend = 'cpu', +): Promise { + if (!Number.isFinite(voxelResolution) || voxelResolution <= 0) { + throw new Error(`carve: voxelResolution must be finite and > 0, got ${voxelResolution}`); + } + if (!Number.isFinite(capsuleHeight) || capsuleHeight <= 0) { + throw new Error(`carve: capsuleHeight must be finite and > 0, got ${capsuleHeight}`); + } + if (!Number.isFinite(capsuleRadius) || capsuleRadius < 0) { + throw new Error(`carve: capsuleRadius must be finite and >= 0, got ${capsuleRadius}`); + } + const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); + const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); + const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); + if (nx % 4 !== 0 || ny % 4 !== 0 || nz % 4 !== 0) { + throw new Error(`carve: grid dimensions must be multiples of 4, got ${nx}x${ny}x${nz}`); + } + const kernelR = Math.ceil(capsuleRadius / voxelResolution); + const yHalfExtent = Math.ceil(capsuleHeight / (2 * voxelResolution)); + const nbx = nx >> 2; + const nby = ny >> 2; + const nbz = nz >> 2; + const blocked = await dilate3(grid, kernelR, yHalfExtent, backend); + const seedIx = Math.floor((seed.x - gridBounds.min.x) / voxelResolution); + const seedIy = Math.floor((seed.y - gridBounds.min.y) / voxelResolution); + const seedIz = Math.floor((seed.z - gridBounds.min.z) / voxelResolution); + if (seedIx < 0 || seedIx >= nx || seedIy < 0 || seedIy >= ny || seedIz < 0 || seedIz >= nz) { + logger.warn(`carve: seed (${seed.x}, ${seed.y}, ${seed.z}) outside grid, skipping`); + return { grid, gridBounds: cloneBounds(gridBounds) }; + } + let useSeedIx = seedIx, + useSeedIy = seedIy, + useSeedIz = seedIz; + if (blocked.getVoxel(seedIx, seedIy, seedIz)) { + const maxRadius = Math.max(kernelR, yHalfExtent) * 2; + const found = SparseVoxelGrid.findNearestFreeCell(blocked, seedIx, seedIy, seedIz, maxRadius); + if (!found) { + logger.warn( + `carve: seed (${seed.x}, ${seed.y}, ${seed.z}) blocked after dilation, no free cell within ${maxRadius} voxels, skipping`, + ); + return { grid, gridBounds: cloneBounds(gridBounds) }; + } + useSeedIx = found.ix; + useSeedIy = found.iy; + useSeedIz = found.iz; + } + const seedBlockIdx = (useSeedIx >> 2) + (useSeedIy >> 2) * nbx + (useSeedIz >> 2) * (nbx * nby); + const seedBt = readBlockType(blocked.types, seedBlockIdx); + const blockSeeds = seedBt === BLOCK_EMPTY ? [seedBlockIdx] : []; + const voxelSeeds = seedBt === BLOCK_EMPTY ? [] : [{ ix: useSeedIx, iy: useSeedIy, iz: useSeedIz }]; + const visited = twoLevelBFS(blocked, blockSeeds, voxelSeeds, nx, ny, nz); + // useless? + const emptyGrid = computeEmptyGrid(visited, blocked); + const navRegion = await dilate3(emptyGrid, kernelR, yHalfExtent, backend); + const navBounds = navRegion.getOccupiedBlockBounds(); + if (!navBounds) { + logger.warn('carve: no navigable cells remain, returning empty result'); + return { + grid: new SparseVoxelGrid(4, 4, 4), + gridBounds: { min: { ...gridBounds.min }, max: { ...gridBounds.min } }, + }; + } + + const MARGIN = 1; + const cropMinBx = Math.max(0, navBounds.minBx - MARGIN); + const cropMinBy = Math.max(0, navBounds.minBy - MARGIN); + const cropMinBz = Math.max(0, navBounds.minBz - MARGIN); + const cropMaxBx = Math.min(nbx, navBounds.maxBx + 1 + MARGIN); + const cropMaxBy = Math.min(nby, navBounds.maxBy + 1 + MARGIN); + const cropMaxBz = Math.min(nbz, navBounds.maxBz + 1 + MARGIN); + + const blockSize = 4 * voxelResolution; + const croppedMin = { + x: gridBounds.min.x + cropMinBx * blockSize, + y: gridBounds.min.y + cropMinBy * blockSize, + z: gridBounds.min.z + cropMinBz * blockSize, + }; + const croppedBounds: Bounds = { + min: croppedMin, + max: { + x: croppedMin.x + (cropMaxBx - cropMinBx) * blockSize, + y: croppedMin.y + (cropMaxBy - cropMinBy) * blockSize, + z: croppedMin.z + (cropMaxBz - cropMinBz) * blockSize, + }, + }; + + return { + grid: navRegion.cropToInverted(cropMinBx, cropMinBy, cropMinBz, cropMaxBx, cropMaxBy, cropMaxBz), + gridBounds: croppedBounds, + }; +} + +/** + * Floor-fill via XZ dilate -> per-column upward walk -> XZ dilate -> OR. + * This mirrors upstream's block/bitmask walk instead of per-voxel getVoxel checks. + */ +export async function fillFloor( + gridOriginal: SparseVoxelGrid, + gridBounds: Bounds, + voxelResolution: number, + dilation = 0, + backend: VoxelBackend = 'cpu', +): Promise { + const nx = Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution); + const ny = Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution); + const nz = Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution); + if (nx % 4 !== 0 || ny % 4 !== 0 || nz % 4 !== 0) { + return { grid: gridOriginal, gridBounds }; + } + + const halfExtent = Math.max(0, Math.ceil(dilation / voxelResolution)); + const dilatedSolid = halfExtent > 0 ? await dilate3(gridOriginal, halfExtent, 0, backend) : gridOriginal; + const { nbx, nby, nbz, bStride } = gridOriginal; + + const foundEmpty = new SparseVoxelGrid(nx, ny, nz); + const dilatedTypes = dilatedSolid.types; + for (let bz = 0; bz < nbz; bz++) { + for (let bx = 0; bx < nbx; bx++) { + let walking = 0xffff; + for (let by = 0; by < nby && walking; by++) { + const blockIdx = bx + by * nbx + bz * bStride; + const bt = readBlockType(dilatedTypes, blockIdx); + if (bt === BLOCK_SOLID) { + break; + } + if (bt === BLOCK_EMPTY) { + if (walking === 0xffff) { + foundEmpty.orBlock(blockIdx, SOLID_LO, SOLID_HI); + } else { + let lo = 0; + let hi = 0; + for (let lz = 0; lz < 4; lz++) { + for (let lx = 0; lx < 4; lx++) { + if (!(walking & (1 << (lz * 4 + lx)))) { + continue; + } + for (let ly = 0; ly < 4; ly++) { + const bitIdx = lx + (ly << 2) + (lz << 4); + if (bitIdx < 32) { + lo |= 1 << bitIdx; + } else { + hi |= 1 << (bitIdx - 32); + } + } + } + } + foundEmpty.orBlock(blockIdx, lo >>> 0, hi >>> 0); + } + continue; + } + + const s = dilatedSolid.masks.slot(blockIdx); + const dLo = dilatedSolid.masks.lo[s]; + const dHi = dilatedSolid.masks.hi[s]; + let foundLo = 0; + let foundHi = 0; + for (let lz = 0; lz < 4; lz++) { + for (let lx = 0; lx < 4; lx++) { + const subCol = 1 << (lz * 4 + lx); + if (!(walking & subCol)) { + continue; + } + for (let ly = 0; ly < 4; ly++) { + const bitIdx = lx + (ly << 2) + (lz << 4); + const inHi = bitIdx >= 32; + const word = inHi ? dHi : dLo; + const bit = 1 << (inHi ? bitIdx - 32 : bitIdx); + if (word & bit) { + walking &= ~subCol; + break; + } + if (inHi) { + foundHi |= bit; + } else { + foundLo |= bit; + } + } + } + } + if (foundLo || foundHi) { + foundEmpty.orBlock(blockIdx, foundLo >>> 0, foundHi >>> 0); + } + } + } + } + if (halfExtent > 0) { + dilatedSolid.clear(); + } + + const foundDilated = halfExtent > 0 ? await dilate3(foundEmpty, halfExtent, 0, backend) : foundEmpty; + const combined = sparseOrGrids(gridOriginal, foundDilated, true); + return { grid: combined, gridBounds: cloneBounds(gridBounds) }; +} + +export type { NavSeed, Bounds }; diff --git a/packages/splat-transform/src/utils/voxel/postprocess.ts b/packages/splat-transform/src/utils/voxel/postprocess.ts new file mode 100644 index 0000000..4b7c29f --- /dev/null +++ b/packages/splat-transform/src/utils/voxel/postprocess.ts @@ -0,0 +1,565 @@ +import { popcount, BlockMaskBuffer, type SparseVoxelGrid, type Bounds, SOLID_HI, SOLID_LO } from './common.js'; +import { logger } from '../Logger.js'; + +const FACE_X0 = 0x11111111; +const FACE_X3 = 0x88888888; +const FACE_Y0 = 0x000f000f; +const FACE_Y3 = 0xf000f000; +const FACE_Z0_LO = 0x0000ffff; +const FACE_Z3_HI = 0xffff0000 >>> 0; + +function sortedUint32Has(sorted: Float64Array, value: number) { + let lo = 0; + let hi = sorted.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const v = sorted[mid]; + if (v < value) { + lo = mid + 1; + } else if (v > value) { + hi = mid - 1; + } else { + return true; + } + } + return false; +} + +function findMixedBlockIndex(sortedBlockIdx: Float64Array, target: number) { + let lo = 0; + let hi = sortedBlockIdx.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const v = sortedBlockIdx[mid]; + if (v < target) { + lo = mid + 1; + } else if (v > target) { + hi = mid - 1; + } else { + return mid; + } + } + return undefined; +} + +function sortMixedByBlockIdx(blockIdx: Float64Array, masks: Uint32Array): void { + const n = blockIdx.length; + if (n <= 1) { + return; + } + const stackLo: number[] = [0]; + const stackHi: number[] = [n - 1]; + function swap(a: number, b: number) { + const k = blockIdx[a]; + blockIdx[a] = blockIdx[b]; + blockIdx[b] = k; + const alo = masks[a * 2]; + const ahi = masks[a * 2 + 1]; + masks[a * 2] = masks[b * 2]; + masks[a * 2 + 1] = masks[b * 2 + 1]; + masks[b * 2] = alo; + masks[b * 2 + 1] = ahi; + } + while (stackLo.length > 0) { + const lo = stackLo.pop()!; + const hi = stackHi.pop()!; + if (hi - lo < 16) { + for (let i = lo + 1; i <= hi; i++) { + const k = blockIdx[i]; + const m0 = masks[i * 2]; + const m1 = masks[i * 2 + 1]; + let j = i - 1; + while (j >= lo && blockIdx[j] > k) { + blockIdx[j + 1] = blockIdx[j]; + masks[(j + 1) * 2] = masks[j * 2]; + masks[(j + 1) * 2 + 1] = masks[j * 2 + 1]; + j--; + } + blockIdx[j + 1] = k; + masks[(j + 1) * 2] = m0; + masks[(j + 1) * 2 + 1] = m1; + } + continue; + } + const mid = (lo + hi) >>> 1; + if (blockIdx[mid] < blockIdx[lo]) { + swap(mid, lo); + } + if (blockIdx[hi] < blockIdx[lo]) { + swap(hi, lo); + } + if (blockIdx[hi] < blockIdx[mid]) { + swap(hi, mid); + } + const pivot = blockIdx[mid]; + let i = lo; + let j = hi; + while (i <= j) { + while (blockIdx[i] < pivot) { + i++; + } + while (blockIdx[j] > pivot) { + j--; + } + if (i <= j) { + if (i !== j) { + swap(i, j); + } + i++; + j--; + } + } + if (j - lo > hi - i) { + if (lo < j) { + stackLo.push(lo); + stackHi.push(j); + } + if (i < hi) { + stackLo.push(i); + stackHi.push(hi); + } + } else { + if (i < hi) { + stackLo.push(i); + stackHi.push(hi); + } + if (lo < j) { + stackLo.push(lo); + stackHi.push(j); + } + } + } +} + +function addCrossFace( + nx: number, + ny: number, + nz: number, + nbx: number, + nby: number, + nbz: number, + hasSolid: (blockIdx: number) => boolean, + getMixedIndex: (blockIdx: number) => number | undefined, + masks: Uint32Array, + ourFaceMask: number, + adjFaceMask: number, + shiftAmount: number, + shiftLeft: boolean, + curLo: number, + curHi: number, + write: (lo: number, hi: number) => void, +) { + if (nx < 0 || ny < 0 || nz < 0 || nx >= nbx || ny >= nby || nz >= nbz) { + write(curLo, curHi); + return; + } + const adjBlockIdx = nx + ny * nbx + nz * nbx * nby; + if (hasSolid(adjBlockIdx)) { + write(curLo | ourFaceMask, curHi | ourFaceMask); + return; + } + const adjIdx = getMixedIndex(adjBlockIdx); + if (adjIdx === undefined) { + write(curLo, curHi); + return; + } + const adjLo = masks[adjIdx * 2]; + const adjHi = masks[adjIdx * 2 + 1]; + const faceLo = adjLo & adjFaceMask; + const faceHi = adjHi & adjFaceMask; + if (shiftLeft) { + write(curLo | (faceLo << shiftAmount), curHi | (faceHi << shiftAmount)); + } else { + write(curLo | (faceLo >>> shiftAmount), curHi | (faceHi >>> shiftAmount)); + } +} + +function addCrossFaceZ( + nx: number, + ny: number, + nz: number, + nbx: number, + nby: number, + nbz: number, + hasSolid: (blockIdx: number) => boolean, + getMixedIndex: (blockIdx: number) => number | undefined, + masks: Uint32Array, + plusZ: boolean, + curLo: number, + curHi: number, + write: (lo: number, hi: number) => void, +) { + if (nx < 0 || ny < 0 || nz < 0 || nx >= nbx || ny >= nby || nz >= nbz) { + write(curLo, curHi); + return; + } + const adjBlockIdx = nx + ny * nbx + nz * nbx * nby; + if (hasSolid(adjBlockIdx)) { + if (plusZ) { + write(curLo, curHi | FACE_Z3_HI); + } else { + write(curLo | FACE_Z0_LO, curHi); + } + return; + } + const adjIdx = getMixedIndex(adjBlockIdx); + if (adjIdx === undefined) { + write(curLo, curHi); + return; + } + const adjLo = masks[adjIdx * 2]; + const adjHi = masks[adjIdx * 2 + 1]; + if (plusZ) { + write(curLo, curHi | ((adjLo & FACE_Z0_LO) << 16)); + } else { + write(curLo | ((adjHi & FACE_Z3_HI) >>> 16), curHi); + } +} + +/** + * Block cleanup pass: + * - remove voxels that have no supporting 6-neighborhood occupancy + * - fill single-voxel holes fully enclosed by 6 neighbors + * Includes cross-block neighbor propagation for face-adjacent blocks. + */ +export function filterAndFillBlocks( + blocks: BlockMaskBuffer, + nbx = Infinity, + nby = Infinity, + nbz = Infinity, +): BlockMaskBuffer { + const mixed = blocks.getMixedBlocks(); + const solids = blocks.getSolidBlocks(); + const mixedCount = mixed.blockIdx.length; + const masks = mixed.masks; + if (mixedCount === 0) { + return blocks; + } + + const mixedBlockIdx = new Float64Array(mixedCount); + for (let i = 0; i < mixedCount; i++) { + mixedBlockIdx[i] = mixed.blockIdx[i]; + } + sortMixedByBlockIdx(mixedBlockIdx, masks); + const sortedSolid = new Float64Array(solids.length); + for (let i = 0; i < solids.length; i++) { + sortedSolid[i] = solids[i]; + } + sortedSolid.sort(); + function hasSolid(blockIdx: number): boolean { + return sortedUint32Has(sortedSolid, blockIdx); + } + function getMixedIndex(blockIdx: number): number | undefined { + return findMixedBlockIndex(mixedBlockIdx, blockIdx); + } + + const newMasks = new Uint32Array(masks.length); + let voxelsRemoved = 0; + let voxelsFilled = 0; + for (let i = 0; i < mixedCount; i++) { + const blockIdx = mixedBlockIdx[i]; + const origLo = masks[i * 2]; + const origHi = masks[i * 2 + 1]; + const bx = blockIdx % nbx; + const byBz = (blockIdx / nbx) | 0; + const by = byBz % nby; + const bz = (blockIdx / (nbx * nby)) | 0; + + let pxLo = (origLo >>> 1) & ~FACE_X3; + let pxHi = (origHi >>> 1) & ~FACE_X3; + let mxLo = (origLo << 1) & ~FACE_X0; + let mxHi = (origHi << 1) & ~FACE_X0; + let pyLo = (origLo >>> 4) & ~FACE_Y3; + let pyHi = (origHi >>> 4) & ~FACE_Y3; + let myLo = (origLo << 4) & ~FACE_Y0; + let myHi = (origHi << 4) & ~FACE_Y0; + let pzLo = (origLo >>> 16) | (origHi << 16); + let pzHi = origHi >>> 16; + let mzLo = origLo << 16; + let mzHi = (origHi << 16) | (origLo >>> 16); + + function writePx(lo: number, hi: number): void { + pxLo = lo; + pxHi = hi; + } + function writeMx(lo: number, hi: number): void { + mxLo = lo; + mxHi = hi; + } + function writePy(lo: number, hi: number): void { + pyLo = lo; + pyHi = hi; + } + function writeMy(lo: number, hi: number): void { + myLo = lo; + myHi = hi; + } + function writePz(lo: number, hi: number): void { + pzLo = lo; + pzHi = hi; + } + function writeMz(lo: number, hi: number): void { + mzLo = lo; + mzHi = hi; + } + + addCrossFace( + bx + 1, + by, + bz, + nbx, + nby, + nbz, + hasSolid, + getMixedIndex, + masks, + FACE_X3, + FACE_X0, + 3, + true, + pxLo, + pxHi, + writePx, + ); + addCrossFace( + bx - 1, + by, + bz, + nbx, + nby, + nbz, + hasSolid, + getMixedIndex, + masks, + FACE_X0, + FACE_X3, + 3, + false, + mxLo, + mxHi, + writeMx, + ); + addCrossFace( + bx, + by + 1, + bz, + nbx, + nby, + nbz, + hasSolid, + getMixedIndex, + masks, + FACE_Y3, + FACE_Y0, + 12, + true, + pyLo, + pyHi, + writePy, + ); + addCrossFace( + bx, + by - 1, + bz, + nbx, + nby, + nbz, + hasSolid, + getMixedIndex, + masks, + FACE_Y0, + FACE_Y3, + 12, + false, + myLo, + myHi, + writeMy, + ); + addCrossFaceZ(bx, by, bz + 1, nbx, nby, nbz, hasSolid, getMixedIndex, masks, true, pzLo, pzHi, writePz); + addCrossFaceZ(bx, by, bz - 1, nbx, nby, nbz, hasSolid, getMixedIndex, masks, false, mzLo, mzHi, writeMz); + + const neighborLo = pxLo | mxLo | pyLo | myLo | pzLo | mzLo; + const neighborHi = pxHi | mxHi | pyHi | myHi | pzHi | mzHi; + let lo = origLo & neighborLo; + let hi = origHi & neighborHi; + const fillLo = ~lo & pxLo & mxLo & pyLo & myLo & pzLo & mzLo; + const fillHi = ~hi & pxHi & mxHi & pyHi & myHi & pzHi & mzHi; + lo |= fillLo; + hi |= fillHi; + voxelsRemoved += popcount(origLo & ~lo) + popcount(origHi & ~hi); + voxelsFilled += popcount(lo & ~origLo) + popcount(hi & ~origHi); + newMasks[i * 2] = lo >>> 0; + newMasks[i * 2 + 1] = hi >>> 0; + } + + const result = new BlockMaskBuffer(); + for (let i = 0; i < mixedCount; i++) { + const lo = newMasks[i * 2]; + const hi = newMasks[i * 2 + 1]; + result.addBlock(mixedBlockIdx[i], lo, hi); + } + for (let i = 0; i < solids.length; i++) { + result.addBlock(solids[i], SOLID_LO, SOLID_HI); + } + logger.info(`voxel filter: ${voxelsRemoved} voxels removed, ${voxelsFilled} voxels filled`); + return result; +} + +export type { Bounds } from './common.js'; + +/** Crop blocks into [min, max) block range and rebase linear block coordinates. */ +export function cropBlocksToRange( + blocks: BlockMaskBuffer, + sourceNbx: number, + sourceNby: number, + cropMinBx: number, + cropMinBy: number, + cropMinBz: number, + cropMaxBx: number, + cropMaxBy: number, + cropMaxBz: number, +): BlockMaskBuffer { + const cropped = new BlockMaskBuffer(); + const outNbx = cropMaxBx - cropMinBx; + const outNby = cropMaxBy - cropMinBy; + const sourceBStride = sourceNbx * sourceNby; + const solids = blocks.getSolidBlocks(); + for (let i = 0; i < solids.length; i++) { + const blockIdx = solids[i]; + const bx = blockIdx % sourceNbx; + const byBz = (blockIdx / sourceNbx) | 0; + const by = byBz % sourceNby; + const bz = (blockIdx / sourceBStride) | 0; + if (bx < cropMinBx || by < cropMinBy || bz < cropMinBz) { + continue; + } + if (bx >= cropMaxBx || by >= cropMaxBy || bz >= cropMaxBz) { + continue; + } + cropped.addBlock( + bx - cropMinBx + (by - cropMinBy) * outNbx + (bz - cropMinBz) * outNbx * outNby, + SOLID_LO, + SOLID_HI, + ); + } + const mixed = blocks.getMixedBlocks(); + for (let i = 0; i < mixed.blockIdx.length; i++) { + const blockIdx = mixed.blockIdx[i]; + const bx = blockIdx % sourceNbx; + const byBz = (blockIdx / sourceNbx) | 0; + const by = byBz % sourceNby; + const bz = (blockIdx / sourceBStride) | 0; + if (bx < cropMinBx || by < cropMinBy || bz < cropMinBz) { + continue; + } + if (bx >= cropMaxBx || by >= cropMaxBy || bz >= cropMaxBz) { + continue; + } + cropped.addBlock( + bx - cropMinBx + (by - cropMinBy) * outNbx + (bz - cropMinBz) * outNbx * outNby, + mixed.masks[i * 2], + mixed.masks[i * 2 + 1], + ); + } + return cropped; +} + +/** Compute world-space bounds corresponding to a cropped block range. */ +export function cropBounds( + gridBounds: Bounds, + voxelResolution: number, + cropMinBx: number, + cropMinBy: number, + cropMinBz: number, + cropMaxBx: number, + cropMaxBy: number, + cropMaxBz: number, +): Bounds { + const blockSize = 4 * voxelResolution; + const croppedMin = { + x: gridBounds.min.x + cropMinBx * blockSize, + y: gridBounds.min.y + cropMinBy * blockSize, + z: gridBounds.min.z + cropMinBz * blockSize, + }; + return { + min: croppedMin, + max: { + x: croppedMin.x + (cropMaxBx - cropMinBx) * blockSize, + y: croppedMin.y + (cropMaxBy - cropMinBy) * blockSize, + z: croppedMin.z + (cropMaxBz - cropMinBz) * blockSize, + }, + }; +} + +/** Tight crop to occupied block bounds. */ +export function cropToOccupied( + grid: SparseVoxelGrid, + gridBounds: Bounds, + voxelResolution: number, +): { grid: SparseVoxelGrid; gridBounds: Bounds } { + const occupied = grid.getOccupiedBlockBounds(); + if (!occupied) { + return { grid, gridBounds }; + } + const { minBx, minBy, minBz, maxBx, maxBy, maxBz } = occupied; + const cropMaxBx = maxBx + 1; + const cropMaxBy = maxBy + 1; + const cropMaxBz = maxBz + 1; + const { nbx, nby, nbz } = grid; + if (minBx === 0 && minBy === 0 && minBz === 0 && cropMaxBx === nbx && cropMaxBy === nby && cropMaxBz === nbz) { + return { grid, gridBounds }; + } + return { + grid: grid.cropTo(minBx, minBy, minBz, cropMaxBx, cropMaxBy, cropMaxBz), + gridBounds: cropBounds(gridBounds, voxelResolution, minBx, minBy, minBz, cropMaxBx, cropMaxBy, cropMaxBz), + }; +} + +/** Tight crop to navigable (non-fully-solid) block bounds. */ +export function cropToNavigable( + grid: SparseVoxelGrid, + gridBounds: Bounds, + voxelResolution: number, +): { grid: SparseVoxelGrid; gridBounds: Bounds } { + const navBounds = grid.getNavigableBlockBounds(); + if (!navBounds) { + return { grid, gridBounds }; + } + const { minBx, minBy, minBz, maxBx, maxBy, maxBz } = navBounds; + const { nbx, nby, nbz } = grid; + + // Keep one solid wall block around the navigable cavity. The runtime + // treats out-of-grid as solid, but collision extraction sees out-of-grid + // as empty; this padding keeps collision meshes sealed at crop edges. + const MARGIN = 1; + const cropMinBx = Math.max(0, minBx - MARGIN); + const cropMinBy = Math.max(0, minBy - MARGIN); + const cropMinBz = Math.max(0, minBz - MARGIN); + const cropMaxBx = Math.min(nbx, maxBx + 1 + MARGIN); + const cropMaxBy = Math.min(nby, maxBy + 1 + MARGIN); + const cropMaxBz = Math.min(nbz, maxBz + 1 + MARGIN); + + if ( + cropMinBx === 0 && + cropMinBy === 0 && + cropMinBz === 0 && + cropMaxBx === nbx && + cropMaxBy === nby && + cropMaxBz === nbz + ) { + return { grid, gridBounds }; + } + return { + grid: grid.cropTo(cropMinBx, cropMinBy, cropMinBz, cropMaxBx, cropMaxBy, cropMaxBz), + gridBounds: cropBounds( + gridBounds, + voxelResolution, + cropMinBx, + cropMinBy, + cropMinBz, + cropMaxBx, + cropMaxBy, + cropMaxBz, + ), + }; +} diff --git a/packages/splat-transform/src/utils/voxel/voxelFaces.ts b/packages/splat-transform/src/utils/voxel/voxelFaces.ts new file mode 100644 index 0000000..16f941d --- /dev/null +++ b/packages/splat-transform/src/utils/voxel/voxelFaces.ts @@ -0,0 +1,736 @@ +import type { Bounds } from './common.js'; +import type { Mesh } from './marchingCubes.js'; +import { BLOCK_EMPTY, BLOCK_SOLID, BLOCKS_PER_WORD, EVEN_BITS, type SparseVoxelGrid, readBlockType } from './common.js'; + +const HASH_MUL = 0x9e3779b9; + +/** + * Extract a watertight voxel-boundary mesh from a SparseVoxelGrid. + * + * Exposed voxel faces are first greedily merged into axis-aligned rectangles. + * Rectangle boundaries are then split at every collinear rectangle corner + * before triangulation, so adjacent rectangles share matching edges instead + * of producing T-junctions. + * + * @param grid - Voxel grid after filtering / nav phases. + * @param gridBounds - Grid bounds aligned to block boundaries. + * @param voxelResolution - Size of each voxel in world units. + * @returns Mesh with positions and indices. + */ +function voxelFaces(grid: SparseVoxelGrid, gridBounds: Bounds, voxelResolution: number): Mesh { + const { nbx, nby, nbz, bStride, types, masks, nx, ny, nz } = grid; + const totalBlocks = nbx * nby * nbz; + const coordStride = Math.max(nx, ny, nz) + 1; + + let faceCap = 1024; + let faceLen = 0; + let faceKeys = new Float64Array(faceCap); + + function addFace(bucket: number, p: number, u: number, v: number): void { + if (faceLen === faceCap) { + faceCap *= 2; + const grown = new Float64Array(faceCap); + grown.set(faceKeys); + faceKeys = grown; + } + faceKeys[faceLen++] = ((bucket * coordStride + p) * coordStride + u) * coordStride + v; + } + + function blockTypeAt(bx: number, by: number, bz: number): number { + if (bx < 0 || by < 0 || bz < 0 || bx >= nbx || by >= nby || bz >= nbz) { + return BLOCK_EMPTY; + } + return readBlockType(types, bx + by * nbx + bz * bStride); + } + + function isVoxelSetLocal(lo: number, hi: number, lx: number, ly: number, lz: number): boolean { + const bitIdx = lx + (ly << 2) + (lz << 4); + return bitIdx < 32 ? ((lo >>> bitIdx) & 1) !== 0 : ((hi >>> (bitIdx - 32)) & 1) !== 0; + } + + function isVoxelSetGlobal(ix: number, iy: number, iz: number): boolean { + if (ix < 0 || iy < 0 || iz < 0 || ix >= nx || iy >= ny || iz >= nz) { + return false; + } + const blockIdx = (ix >> 2) + (iy >> 2) * nbx + (iz >> 2) * bStride; + const bt = readBlockType(types, blockIdx); + if (bt === BLOCK_EMPTY) { + return false; + } + if (bt === BLOCK_SOLID) { + return true; + } + const s = masks.slot(blockIdx); + return isVoxelSetLocal(masks.lo[s], masks.hi[s], ix & 3, iy & 3, iz & 3); + } + + function addVoxelFace(ix: number, iy: number, iz: number, bucket: number): void { + switch (bucket) { + case 0: + addFace(0, ix, iy, iz); + break; // -X + case 1: + addFace(1, ix + 1, iy, iz); + break; // +X + case 2: + addFace(2, iy, ix, iz); + break; // -Y + case 3: + addFace(3, iy + 1, ix, iz); + break; // +Y + case 4: + addFace(4, iz, ix, iy); + break; // -Z + default: + addFace(5, iz + 1, ix, iy); + break; // +Z + } + } + + function processSolidBlock(bx: number, by: number, bz: number): void { + const x0 = bx << 2; + const y0 = by << 2; + const z0 = bz << 2; + + function emitX(bucket: number, neighborBlockType: number, ix: number, nx2: number): void { + if (neighborBlockType === BLOCK_SOLID) { + return; + } + for (let lz = 0; lz < 4; lz++) { + const iz = z0 + lz; + for (let ly = 0; ly < 4; ly++) { + const iy = y0 + ly; + if (neighborBlockType === BLOCK_EMPTY || !isVoxelSetGlobal(nx2, iy, iz)) { + addVoxelFace(ix, iy, iz, bucket); + } + } + } + } + + function emitY(bucket: number, neighborBlockType: number, iy: number, ny2: number): void { + if (neighborBlockType === BLOCK_SOLID) { + return; + } + for (let lz = 0; lz < 4; lz++) { + const iz = z0 + lz; + for (let lx = 0; lx < 4; lx++) { + const ix = x0 + lx; + if (neighborBlockType === BLOCK_EMPTY || !isVoxelSetGlobal(ix, ny2, iz)) { + addVoxelFace(ix, iy, iz, bucket); + } + } + } + } + + function emitZ(bucket: number, neighborBlockType: number, iz: number, nz2: number): void { + if (neighborBlockType === BLOCK_SOLID) { + return; + } + for (let ly = 0; ly < 4; ly++) { + const iy = y0 + ly; + for (let lx = 0; lx < 4; lx++) { + const ix = x0 + lx; + if (neighborBlockType === BLOCK_EMPTY || !isVoxelSetGlobal(ix, iy, nz2)) { + addVoxelFace(ix, iy, iz, bucket); + } + } + } + } + + emitX(0, blockTypeAt(bx - 1, by, bz), x0, x0 - 1); + emitX(1, blockTypeAt(bx + 1, by, bz), x0 + 3, x0 + 4); + emitY(2, blockTypeAt(bx, by - 1, bz), y0, y0 - 1); + emitY(3, blockTypeAt(bx, by + 1, bz), y0 + 3, y0 + 4); + emitZ(4, blockTypeAt(bx, by, bz - 1), z0, z0 - 1); + emitZ(5, blockTypeAt(bx, by, bz + 1), z0 + 3, z0 + 4); + } + + function processMixedBlock(blockIdx: number, bx: number, by: number, bz: number): void { + const s = masks.slot(blockIdx); + const lo = masks.lo[s]; + const hi = masks.hi[s]; + const x0 = bx << 2; + const y0 = by << 2; + const z0 = bz << 2; + + for (let lz = 0; lz < 4; lz++) { + const iz = z0 + lz; + for (let ly = 0; ly < 4; ly++) { + const iy = y0 + ly; + for (let lx = 0; lx < 4; lx++) { + if (!isVoxelSetLocal(lo, hi, lx, ly, lz)) { + continue; + } + const ix = x0 + lx; + if (!isVoxelSetGlobal(ix - 1, iy, iz)) { + addVoxelFace(ix, iy, iz, 0); + } + if (!isVoxelSetGlobal(ix + 1, iy, iz)) { + addVoxelFace(ix, iy, iz, 1); + } + if (!isVoxelSetGlobal(ix, iy - 1, iz)) { + addVoxelFace(ix, iy, iz, 2); + } + if (!isVoxelSetGlobal(ix, iy + 1, iz)) { + addVoxelFace(ix, iy, iz, 3); + } + if (!isVoxelSetGlobal(ix, iy, iz - 1)) { + addVoxelFace(ix, iy, iz, 4); + } + if (!isVoxelSetGlobal(ix, iy, iz + 1)) { + addVoxelFace(ix, iy, iz, 5); + } + } + } + } + } + + for (let w = 0; w < types.length; w++) { + const word = types[w]; + if (word === 0) { + continue; + } + let nonEmpty = ((word & EVEN_BITS) | ((word >>> 1) & EVEN_BITS)) >>> 0; + const baseBlockIdx = w * BLOCKS_PER_WORD; + while (nonEmpty) { + const bp = 31 - Math.clz32(nonEmpty & -nonEmpty); + const lane = bp >>> 1; + const blockIdx = baseBlockIdx + lane; + nonEmpty &= nonEmpty - 1; + if (blockIdx >= totalBlocks) { + break; + } + + const bx = blockIdx % nbx; + const byBz = (blockIdx / nbx) | 0; + const by = byBz % nby; + const bz = (byBz / nby) | 0; + const bt = (word >>> (lane << 1)) & 3; + if (bt === BLOCK_SOLID) { + processSolidBlock(bx, by, bz); + } else { + processMixedBlock(blockIdx, bx, by, bz); + } + } + } + + if (faceLen === 0) { + return { positions: new Float32Array(0), indices: new Uint32Array(0) }; + } + + let rectCap = 1024; + let rectLen = 0; + let rectBucket: Int32Array = new Int32Array(rectCap); + let rectP: Int32Array = new Int32Array(rectCap); + let rectU0: Int32Array = new Int32Array(rectCap); + let rectV0: Int32Array = new Int32Array(rectCap); + let rectU1: Int32Array = new Int32Array(rectCap); + let rectV1: Int32Array = new Int32Array(rectCap); + + function addRect(bucket: number, p: number, u0: number, v0: number, u1: number, v1: number): void { + if (rectLen === rectCap) { + rectCap *= 2; + function grow(src: Int32Array): Int32Array { + const out = new Int32Array(rectCap); + out.set(src); + return out; + } + rectBucket = grow(rectBucket); + rectP = grow(rectP); + rectU0 = grow(rectU0); + rectV0 = grow(rectV0); + rectU1 = grow(rectU1); + rectV1 = grow(rectV1); + } + rectBucket[rectLen] = bucket; + rectP[rectLen] = p; + rectU0[rectLen] = u0; + rectV0[rectLen] = v0; + rectU1[rectLen] = u1; + rectV1[rectLen] = v1; + rectLen++; + } + + const keys = faceKeys.subarray(0, faceLen); + faceKeys = new Float64Array(0); + keys.sort(); + + function decodeGroup(key: number): { bucket: number; p: number } { + let q = Math.floor(key / coordStride); + q = Math.floor(q / coordStride); + const p = q % coordStride; + const bucket = Math.floor(q / coordStride); + return { bucket, p }; + } + + function decodeUvKey(key: number): number { + const v = key % coordStride; + const q = Math.floor(key / coordStride); + const u = q % coordStride; + return u * coordStride + v; + } + + let groupStart = 0; + while (groupStart < keys.length) { + const { bucket, p } = decodeGroup(keys[groupStart]); + let groupEnd = groupStart + 1; + while (groupEnd < keys.length) { + const g = decodeGroup(keys[groupEnd]); + if (g.bucket !== bucket || g.p !== p) { + break; + } + groupEnd++; + } + + const count = groupEnd - groupStart; + let hCap = 1; + while (hCap < count / 0.7) { + hCap *= 2; + } + const hMask = hCap - 1; + const hKeys = new Float64Array(hCap).fill(-1); + const hVals = new Int32Array(hCap); + + function hash(key: number): number { + const hi = (key / 0x100000000) | 0; + return (Math.imul((key | 0) ^ hi, HASH_MUL) >>> 0) & hMask; + } + + for (let i = 0; i < count; i++) { + const uvKey = decodeUvKey(keys[groupStart + i]); + let h = hash(uvKey); + while (hKeys[h] !== -1) { + h = (h + 1) & hMask; + } + hKeys[h] = uvKey; + hVals[h] = i; + } + + function lookup(uvKey: number): number { + let h = hash(uvKey); + while (true) { + const k = hKeys[h]; + if (k === uvKey) { + return hVals[h]; + } + if (k === -1) { + return -1; + } + h = (h + 1) & hMask; + } + } + + const visited = new Uint8Array(count); + function uvKeyOf(u: number, v: number): number { + return u * coordStride + v; + } + + for (let i = 0; i < count; i++) { + if (visited[i]) { + continue; + } + const uvKey = decodeUvKey(keys[groupStart + i]); + const u0 = Math.floor(uvKey / coordStride); + const v0 = uvKey % coordStride; + + let width = 1; + while (true) { + const idx = lookup(uvKeyOf(u0 + width, v0)); + if (idx === -1 || visited[idx]) { + break; + } + width++; + } + + let height = 1; + while (true) { + let canGrow = true; + for (let du = 0; du < width; du++) { + const idx = lookup(uvKeyOf(u0 + du, v0 + height)); + if (idx === -1 || visited[idx]) { + canGrow = false; + break; + } + } + if (!canGrow) { + break; + } + height++; + } + + for (let dv = 0; dv < height; dv++) { + for (let du = 0; du < width; du++) { + visited[lookup(uvKeyOf(u0 + du, v0 + dv))] = 1; + } + } + + addRect(bucket, p, u0, v0, u0 + width, v0 + height); + } + + groupStart = groupEnd; + } + + function globalPoint(axis: number, p: number, u: number, v: number): [number, number, number] { + if (axis === 0) { + return [p, u, v]; + } + if (axis === 1) { + return [u, p, v]; + } + return [u, v, p]; + } + + function lineKey(varAxis: number, x: number, y: number, z: number): number { + if (varAxis === 0) { + return (y + z * coordStride) * 3; + } + if (varAxis === 1) { + return (x + z * coordStride) * 3 + 1; + } + return (x + y * coordStride) * 3 + 2; + } + + const linePoints = new Map(); + + function addLinePoint(key: number, value: number): void { + let points = linePoints.get(key); + if (!points) { + points = []; + linePoints.set(key, points); + } + points.push(value); + } + + function addLineSegment(x0: number, y0: number, z0: number, x1: number, y1: number, z1: number): void { + if (x0 !== x1) { + const key = lineKey(0, x0, y0, z0); + addLinePoint(key, x0); + addLinePoint(key, x1); + } else if (y0 !== y1) { + const key = lineKey(1, x0, y0, z0); + addLinePoint(key, y0); + addLinePoint(key, y1); + } else { + const key = lineKey(2, x0, y0, z0); + addLinePoint(key, z0); + addLinePoint(key, z1); + } + } + + for (let r = 0; r < rectLen; r++) { + const axis = rectBucket[r] >> 1; + const p = rectP[r]; + const a = globalPoint(axis, p, rectU0[r], rectV0[r]); + const b = globalPoint(axis, p, rectU1[r], rectV0[r]); + const c = globalPoint(axis, p, rectU1[r], rectV1[r]); + const d = globalPoint(axis, p, rectU0[r], rectV1[r]); + addLineSegment(a[0], a[1], a[2], b[0], b[1], b[2]); + addLineSegment(b[0], b[1], b[2], c[0], c[1], c[2]); + addLineSegment(c[0], c[1], c[2], d[0], d[1], d[2]); + addLineSegment(d[0], d[1], d[2], a[0], a[1], a[2]); + } + + for (const points of linePoints.values()) { + points.sort((a, b) => a - b); + let write = 0; + for (let i = 0; i < points.length; i++) { + if (i === 0 || points[i] !== points[i - 1]) { + points[write++] = points[i]; + } + } + points.length = write; + } + + let posCap = 1024; + let posLen = 0; + let positions = new Float32Array(posCap); + let idxCap = 1024; + let idxLen = 0; + let indices = new Uint32Array(idxCap); + const vertexMap = new Map(); + let perimeterScratch = new Uint32Array(16); + let perimeterU = new Int32Array(16); + let perimeterV = new Int32Array(16); + let perimeterLen = 0; + let triPrev = new Int32Array(16); + let triNext = new Int32Array(16); + + function addPosition(x: number, y: number, z: number): number { + if (posLen + 3 > posCap) { + posCap *= 2; + const grown = new Float32Array(posCap); + grown.set(positions); + positions = grown; + } + const idx = posLen / 3; + positions[posLen++] = gridBounds.min.x + x * voxelResolution; + positions[posLen++] = gridBounds.min.y + y * voxelResolution; + positions[posLen++] = gridBounds.min.z + z * voxelResolution; + return idx; + } + + function vertexKey(x: number, y: number, z: number): number { + return x + y * coordStride + z * coordStride * coordStride; + } + + function getVertex(x: number, y: number, z: number): number { + const key = vertexKey(x, y, z); + const existing = vertexMap.get(key); + if (existing !== undefined) { + return existing; + } + const idx = addPosition(x, y, z); + vertexMap.set(key, idx); + return idx; + } + + function ensureIndexCapacity(additional: number): void { + if (idxLen + additional <= idxCap) { + return; + } + while (idxLen + additional > idxCap) { + idxCap *= 2; + } + const grown = new Uint32Array(idxCap); + grown.set(indices); + indices = grown; + } + + function appendTri(a: number, b: number, c: number): void { + ensureIndexCapacity(3); + indices[idxLen++] = a; + indices[idxLen++] = b; + indices[idxLen++] = c; + } + + function resetPerimeter(): void { + perimeterLen = 0; + } + + function localUv(axis: number, x: number, y: number, z: number): [number, number] { + if (axis === 0) { + return [y, z]; + } + if (axis === 1) { + return [x, z]; + } + return [x, y]; + } + + function addPerimeterVertex(v: number, u: number, pv: number): void { + if (perimeterLen > 0 && perimeterScratch[perimeterLen - 1] === v) { + return; + } + if (perimeterLen === perimeterScratch.length) { + const grown = new Uint32Array(perimeterScratch.length * 2); + grown.set(perimeterScratch); + perimeterScratch = grown; + const grownU = new Int32Array(perimeterU.length * 2); + grownU.set(perimeterU); + perimeterU = grownU; + const grownV = new Int32Array(perimeterV.length * 2); + grownV.set(perimeterV); + perimeterV = grownV; + } + perimeterScratch[perimeterLen++] = v; + perimeterU[perimeterLen - 1] = u; + perimeterV[perimeterLen - 1] = pv; + } + + function addEdgeVertices( + axis: number, + x0: number, + y0: number, + z0: number, + x1: number, + y1: number, + z1: number, + ): void { + let varAxis: number; + let start: number; + let end: number; + if (x0 !== x1) { + varAxis = 0; + start = x0; + end = x1; + } else if (y0 !== y1) { + varAxis = 1; + start = y0; + end = y1; + } else { + varAxis = 2; + start = z0; + end = z1; + } + + const key = lineKey(varAxis, x0, y0, z0); + const points = linePoints.get(key); + if (!points) { + return; + } + + const lo = Math.min(start, end); + const hi = Math.max(start, end); + const forward = start <= end; + + function emitPoint(x: number, y: number, z: number): void { + const [u, v] = localUv(axis, x, y, z); + addPerimeterVertex(getVertex(x, y, z), u, v); + } + + if (forward) { + for (let i = 0; i < points.length; i++) { + const t = points[i]; + if (t < lo) { + continue; + } + if (t > hi) { + break; + } + if (varAxis === 0) { + emitPoint(t, y0, z0); + } else if (varAxis === 1) { + emitPoint(x0, t, z0); + } else { + emitPoint(x0, y0, t); + } + } + } else { + for (let i = points.length - 1; i >= 0; i--) { + const t = points[i]; + if (t > hi) { + continue; + } + if (t < lo) { + break; + } + if (varAxis === 0) { + emitPoint(t, y0, z0); + } else if (varAxis === 1) { + emitPoint(x0, t, z0); + } else { + emitPoint(x0, y0, t); + } + } + } + } + + function isConvexEar(prev: number, curr: number, next: number): boolean { + const ax = perimeterU[curr] - perimeterU[prev]; + const ay = perimeterV[curr] - perimeterV[prev]; + const bx = perimeterU[next] - perimeterU[prev]; + const by = perimeterV[next] - perimeterV[prev]; + return ax * by - ay * bx > 0; + } + + function isNonDegenerateTri(a: number, b: number, c: number): boolean { + const ax = perimeterU[b] - perimeterU[a]; + const ay = perimeterV[b] - perimeterV[a]; + const bx = perimeterU[c] - perimeterU[a]; + const by = perimeterV[c] - perimeterV[a]; + return ax * by - ay * bx > 0; + } + + function appendOrientedTri(a: number, b: number, c: number, useLocalCcw: boolean): void { + if (useLocalCcw) { + appendTri(a, b, c); + } else { + appendTri(a, c, b); + } + } + + function triangulatePerimeter(useLocalCcw: boolean): void { + if (perimeterLen < 3) { + return; + } + + if (perimeterLen > triPrev.length) { + let cap = triPrev.length; + while (perimeterLen > cap) { + cap *= 2; + } + triPrev = new Int32Array(cap); + triNext = new Int32Array(cap); + } + + for (let i = 0; i < perimeterLen; i++) { + triPrev[i] = i === 0 ? perimeterLen - 1 : i - 1; + triNext[i] = i === perimeterLen - 1 ? 0 : i + 1; + } + + let remaining = perimeterLen; + let current = 0; + let attempts = 0; + + while (remaining > 3 && attempts < remaining) { + const prev = triPrev[current]; + const next = triNext[current]; + const next2 = triNext[next]; + const keepsArea = remaining !== 4 || isNonDegenerateTri(prev, next, next2); + if (keepsArea && isConvexEar(prev, current, next)) { + appendOrientedTri( + perimeterScratch[prev], + perimeterScratch[current], + perimeterScratch[next], + useLocalCcw, + ); + triNext[prev] = next; + triPrev[next] = prev; + current = next; + remaining--; + attempts = 0; + } else { + current = next; + attempts++; + } + } + + if (remaining === 3) { + const a = current; + const b = triNext[a]; + const c = triNext[b]; + if (isConvexEar(a, b, c)) { + appendOrientedTri(perimeterScratch[a], perimeterScratch[b], perimeterScratch[c], useLocalCcw); + } + } + } + + for (let r = 0; r < rectLen; r++) { + const bucket = rectBucket[r]; + const axis = bucket >> 1; + const positive = (bucket & 1) === 1; + const p = rectP[r]; + const u0 = rectU0[r]; + const v0 = rectV0[r]; + const u1 = rectU1[r]; + const v1 = rectV1[r]; + const a = globalPoint(axis, p, u0, v0); + const b = globalPoint(axis, p, u1, v0); + const c = globalPoint(axis, p, u1, v1); + const d = globalPoint(axis, p, u0, v1); + + resetPerimeter(); + addEdgeVertices(axis, a[0], a[1], a[2], b[0], b[1], b[2]); + addEdgeVertices(axis, b[0], b[1], b[2], c[0], c[1], c[2]); + addEdgeVertices(axis, c[0], c[1], c[2], d[0], d[1], d[2]); + addEdgeVertices(axis, d[0], d[1], d[2], a[0], a[1], a[2]); + if (perimeterLen > 1 && perimeterScratch[0] === perimeterScratch[perimeterLen - 1]) { + perimeterLen--; + } + if (perimeterLen < 3) { + continue; + } + + const localCcwIsPositive = axis !== 1; + const useLocalCcw = positive === localCcwIsPositive; + + triangulatePerimeter(useLocalCcw); + } + + return { + positions: positions.slice(0, posLen), + indices: indices.slice(0, idxLen), + }; +} + +export { voxelFaces }; diff --git a/packages/splat-transform/src/utils/voxel/voxelize.ts b/packages/splat-transform/src/utils/voxel/voxelize.ts new file mode 100644 index 0000000..7df45c6 --- /dev/null +++ b/packages/splat-transform/src/utils/voxel/voxelize.ts @@ -0,0 +1,1281 @@ +import { getOrCreateDevice } from '../webgpu.js'; +import { logger } from '../Logger.js'; +import { ALPHA_THRESHOLD, BlockMaskBuffer, GaussianBVH, LEAF_SIZE } from './common.js'; +import { availableParallelism } from 'node:os'; +import { Worker } from 'node:worker_threads'; + +/** Per gaussian: increment overlap count for each coarse batch cell its AABB touches (GPU atomics). */ +function buildPerBatchCountsWgsl() { + return /* wgsl */ ` +struct Uniforms { + gridMinX: f32, + gridMinY: f32, + gridMinZ: f32, + batchWorldSize: f32, + numBatchX: u32, + numBatchY: u32, + numBatchZ: u32, + gaussianCount: u32 +} + +struct Gaussian { + posX: f32, posY: f32, posZ: f32, opacity: f32, + rotW: f32, rotX: f32, rotY: f32, rotZ: f32, + scaleX: f32, scaleY: f32, scaleZ: f32, + extentX: f32, extentY: f32, extentZ: f32, + _padding0: f32, _padding1: f32 +} + +@group(0) @binding(0) var uniforms: Uniforms; +@group(0) @binding(1) var allGaussians: array; +@group(0) @binding(2) var batchCounts: array>; + +@compute @workgroup_size(256) +fn main(@builtin(global_invocation_id) global_id: vec3u) { + let gaussianIdx = global_id.x; + if (gaussianIdx >= uniforms.gaussianCount) { return; } + let g = allGaussians[gaussianIdx]; + if (g.opacity <= 0.0) { return; } + let gMinX = g.posX - g.extentX - uniforms.gridMinX; + let gMinY = g.posY - g.extentY - uniforms.gridMinY; + let gMinZ = g.posZ - g.extentZ - uniforms.gridMinZ; + let gMaxX = g.posX + g.extentX - uniforms.gridMinX; + let gMaxY = g.posY + g.extentY - uniforms.gridMinY; + let gMaxZ = g.posZ + g.extentZ - uniforms.gridMinZ; + let maxWorldX = uniforms.batchWorldSize * f32(uniforms.numBatchX); + let maxWorldY = uniforms.batchWorldSize * f32(uniforms.numBatchY); + let maxWorldZ = uniforms.batchWorldSize * f32(uniforms.numBatchZ); + if (gMaxX < 0.0 || gMinX > maxWorldX || gMaxY < 0.0 || gMinY > maxWorldY || gMaxZ < 0.0 || gMinZ > maxWorldZ) { return; } + let minBx = clamp(i32(floor(gMinX / uniforms.batchWorldSize)), 0, i32(uniforms.numBatchX) - 1); + let minBy = clamp(i32(floor(gMinY / uniforms.batchWorldSize)), 0, i32(uniforms.numBatchY) - 1); + let minBz = clamp(i32(floor(gMinZ / uniforms.batchWorldSize)), 0, i32(uniforms.numBatchZ) - 1); + let maxBx = clamp(i32(floor(gMaxX / uniforms.batchWorldSize)), 0, i32(uniforms.numBatchX) - 1); + let maxBy = clamp(i32(floor(gMaxY / uniforms.batchWorldSize)), 0, i32(uniforms.numBatchY) - 1); + let maxBz = clamp(i32(floor(gMaxZ / uniforms.batchWorldSize)), 0, i32(uniforms.numBatchZ) - 1); + for (var bz = minBz; bz <= maxBz; bz++) { + for (var by = minBy; by <= maxBy; by++) { + for (var bx = minBx; bx <= maxBx; bx++) { + let batchId = u32(bz) * uniforms.numBatchX * uniforms.numBatchY + u32(by) * uniforms.numBatchX + u32(bx); + atomicAdd(&batchCounts[batchId], 1u); + } + } + } +} +`; +} + +/** Scatter gaussian indices into packed `indices` using prefix `batchOffsets` and per-batch atomic write heads. */ +function fillPerBatchCandidatesWgsl() { + return /* wgsl */ ` +struct Uniforms { + gridMinX: f32, + gridMinY: f32, + gridMinZ: f32, + batchWorldSize: f32, + numBatchX: u32, + numBatchY: u32, + numBatchZ: u32, + gaussianCount: u32 +} + +struct Gaussian { + posX: f32, posY: f32, posZ: f32, opacity: f32, + rotW: f32, rotX: f32, rotY: f32, rotZ: f32, + scaleX: f32, scaleY: f32, scaleZ: f32, + extentX: f32, extentY: f32, extentZ: f32, + _padding0: f32, _padding1: f32 +} + +@group(0) @binding(0) var uniforms: Uniforms; +@group(0) @binding(1) var allGaussians: array; +@group(0) @binding(2) var batchOffsets: array; +@group(0) @binding(3) var batchWriteHeads: array>; +@group(0) @binding(4) var indices: array; + +@compute @workgroup_size(256) +fn main(@builtin(global_invocation_id) global_id: vec3u) { + let gaussianIdx = global_id.x; + if (gaussianIdx >= uniforms.gaussianCount) { return; } + let g = allGaussians[gaussianIdx]; + if (g.opacity <= 0.0) { return; } + let gMinX = g.posX - g.extentX - uniforms.gridMinX; + let gMinY = g.posY - g.extentY - uniforms.gridMinY; + let gMinZ = g.posZ - g.extentZ - uniforms.gridMinZ; + let gMaxX = g.posX + g.extentX - uniforms.gridMinX; + let gMaxY = g.posY + g.extentY - uniforms.gridMinY; + let gMaxZ = g.posZ + g.extentZ - uniforms.gridMinZ; + let maxWorldX = uniforms.batchWorldSize * f32(uniforms.numBatchX); + let maxWorldY = uniforms.batchWorldSize * f32(uniforms.numBatchY); + let maxWorldZ = uniforms.batchWorldSize * f32(uniforms.numBatchZ); + if (gMaxX < 0.0 || gMinX > maxWorldX || gMaxY < 0.0 || gMinY > maxWorldY || gMaxZ < 0.0 || gMinZ > maxWorldZ) { return; } + let minBx = clamp(i32(floor(gMinX / uniforms.batchWorldSize)), 0, i32(uniforms.numBatchX) - 1); + let minBy = clamp(i32(floor(gMinY / uniforms.batchWorldSize)), 0, i32(uniforms.numBatchY) - 1); + let minBz = clamp(i32(floor(gMinZ / uniforms.batchWorldSize)), 0, i32(uniforms.numBatchZ) - 1); + let maxBx = clamp(i32(floor(gMaxX / uniforms.batchWorldSize)), 0, i32(uniforms.numBatchX) - 1); + let maxBy = clamp(i32(floor(gMaxY / uniforms.batchWorldSize)), 0, i32(uniforms.numBatchY) - 1); + let maxBz = clamp(i32(floor(gMaxZ / uniforms.batchWorldSize)), 0, i32(uniforms.numBatchZ) - 1); + for (var bz = minBz; bz <= maxBz; bz++) { + for (var by = minBy; by <= maxBy; by++) { + for (var bx = minBx; bx <= maxBx; bx++) { + let batchId = u32(bz) * uniforms.numBatchX * uniforms.numBatchY + u32(by) * uniforms.numBatchX + u32(bx); + let local = atomicAdd(&batchWriteHeads[batchId], 1u); + let dst = batchOffsets[batchId] + local; + indices[dst] = gaussianIdx; + } + } + } +} +`; +} + +/** + * From https://github.com/playcanvas/splat-transform/blob/8f3b843efdc378f97d4f6a66a3a90a2de6d479a4/src/lib/gpu/gpu-voxelization.ts + * WGSL shader for multi-batch voxelization of 4x4x4 blocks. + * + * Each workgroup processes one block in one batch. + * - workgroup_id.z = batch index + * - workgroup_id.x = flat block index within the batch + * Per-batch metadata (index range, block origin, dimensions) comes from a storage buffer, + * allowing many batches to be dispatched in a single GPU call. + */ +function voxelizeMultiBatchWgsl() { + return /* wgsl */ ` +struct Uniforms { + opacityCutoff: f32, + voxelResolution: f32, + maxBlocksPerBatch: u32 +} + +struct BatchInfo { + indexOffset: u32, + indexCount: u32, + numBlocksX: u32, + numBlocksY: u32, + numBlocksZ: u32, + blockMinX: f32, + blockMinY: f32, + blockMinZ: f32 +} + +struct Gaussian { + posX: f32, + posY: f32, + posZ: f32, + opacity: f32, + rotW: f32, + rotX: f32, + rotY: f32, + rotZ: f32, + scaleX: f32, + scaleY: f32, + scaleZ: f32, + extentX: f32, + extentY: f32, + extentZ: f32, + _padding0: f32, + _padding1: f32 +} + +@group(0) @binding(0) var uniforms: Uniforms; +@group(0) @binding(1) var allGaussians: array; +@group(0) @binding(2) var indices: array; +@group(0) @binding(3) var results: array>; +@group(0) @binding(4) var batchInfos: array; + +// Shared memory for cooperative Gaussian loading. +// All 64 threads in a workgroup load one Gaussian each, then all threads +// evaluate against the shared chunk (reducing global memory reads by 64x). +// 64 Gaussians * 64 bytes each = 4 KB (well within 16 KB WebGPU minimum). +const tileSize = 64u; +var sharedGaussians: array; +var blockMasks: array, 2>; + +fn mortonToXYZ(m: u32) -> vec3u { + return vec3u( + (m & 1u) | ((m >> 2u) & 2u), + ((m >> 1u) & 1u) | ((m >> 3u) & 2u), + ((m >> 2u) & 1u) | ((m >> 4u) & 2u) + ); +} + +fn evaluateGaussianForVoxel(voxelCenter: vec3f, voxelHalfSize: f32, g: Gaussian) -> f32 { + let gaussianCenter = vec3f(g.posX, g.posY, g.posZ); + let diff = voxelCenter - gaussianCenter; + // Use pre-computed world-space AABB half-extents (3-sigma, accounts for rotation) + let extent = vec3f(g.extentX, g.extentY, g.extentZ); + // Per-axis AABB overlap check + if (any(abs(diff) > (extent + voxelHalfSize))) { + return 0.0; + } + // Find closest point in voxel to Gaussian center + let closestPoint = clamp(gaussianCenter, voxelCenter - voxelHalfSize, voxelCenter + voxelHalfSize); + let closestDiff = closestPoint - gaussianCenter; + // Inverse rotation using cross-product formula (Rodrigues rotation) + // For inverse: negate xyz components of quaternion + let qxyz = vec3f(-g.rotX, -g.rotY, -g.rotZ); + let t = 2.0 * cross(qxyz, closestDiff); + let localDiff = closestDiff + g.rotW * t + cross(qxyz, t); + // Calculate Mahalanobis distance squared + let invScale = vec3f(1.0 / max(g.scaleX, 1e-8), 1.0 / max(g.scaleY, 1e-8), 1.0 / max(g.scaleZ, 1e-8)); + let scaled = localDiff * invScale; + let d2 = dot(scaled, scaled); + return g.opacity * exp(-0.5 * d2); +} + +@compute @workgroup_size(64) +fn main( + @builtin(local_invocation_index) local_invocation_index: u32, + @builtin(workgroup_id) workgroup_id: vec3u +) { + let batchIdx = workgroup_id.z; + let flatBlockId = workgroup_id.x; + let info = batchInfos[batchIdx]; + // Skip padded workgroups beyond the batch's actual block count + let totalBlocks = info.numBlocksX * info.numBlocksY * info.numBlocksZ; + if (flatBlockId >= totalBlocks) { return; } + + // Decompose flat block ID to 3D coordinates within the batch + let blockX = flatBlockId % info.numBlocksX; + let blockY = (flatBlockId / info.numBlocksX) % info.numBlocksY; + let blockZ = flatBlockId / (info.numBlocksX * info.numBlocksY); + let localPos = mortonToXYZ(local_invocation_index); + + let blockMin = vec3f(info.blockMinX, info.blockMinY, info.blockMinZ); + let blockOffset = vec3f(f32(blockX), f32(blockY), f32(blockZ)) * 4.0 * uniforms.voxelResolution; + let voxelCenter = blockMin + blockOffset + (vec3f(localPos) + 0.5) * uniforms.voxelResolution; + let voxelHalfSize = uniforms.voxelResolution * 0.5; + if (local_invocation_index < 2u) { + atomicStore(&blockMasks[local_invocation_index], 0u); + } + workgroupBarrier(); + + var totalSigma = 0.0; + let numIndices = info.indexCount; + let numTiles = (numIndices + tileSize - 1u) / tileSize; + for (var tile = 0u; tile < numTiles; tile++) { + // Cooperative load: each thread loads one Gaussian into shared memory + let loadIdx = tile * tileSize + local_invocation_index; + if (loadIdx < numIndices) { + let gaussianIdx = indices[info.indexOffset + loadIdx]; + sharedGaussians[local_invocation_index] = allGaussians[gaussianIdx]; + } + // Wait for all threads to finish loading the tile + workgroupBarrier(); + + if (totalSigma < 7.0) { + let thisTileSize = min(tileSize, numIndices - tile * tileSize); + for (var c = 0u; c < thisTileSize; c++) { + totalSigma += evaluateGaussianForVoxel(voxelCenter, voxelHalfSize, sharedGaussians[c]); + if (totalSigma >= 7.0) { break; } + } + } + // Wait before next tile overwrites shared memory + workgroupBarrier(); + } + + // Convert accumulated density to opacity using Beer-Lambert law + let finalOpacity = 1.0 - exp(-totalSigma); + let isSolid = finalOpacity >= uniforms.opacityCutoff; + // Accumulate block bits in workgroup-local atomics first to reduce global atomic contention. + if (isSolid) { + let linearIdx = localPos.z * 16u + localPos.y * 4u + localPos.x; + atomicOr(&blockMasks[linearIdx >> 5u], 1u << (linearIdx & 31u)); + } + workgroupBarrier(); + if (local_invocation_index < 2u) { + let batchResultBase = batchIdx * uniforms.maxBlocksPerBatch * 2u; + let wordIndex = batchResultBase + flatBlockId * 2u + local_invocation_index; + atomicStore(&results[wordIndex], atomicLoad(&blockMasks[local_invocation_index])); + } +} +`; +} + +const GPU_BUFFER_USAGE_STORAGE = 128; +const GPU_BUFFER_USAGE_COPY_DST = 8; +const GPU_BUFFER_USAGE_COPY_SRC = 4; +const GPU_BUFFER_USAGE_UNIFORM = 64; +const GPU_BUFFER_USAGE_MAP_READ = 1; +const GPU_MAP_MODE_READ = 1; + +/** + * CPU voxelization fallback (simplified path). + * Iterates candidate gaussians per batch and writes occupied voxel bits directly. + */ +const CPU_VOXEL_PARALLEL_MIN_GAUSSIANS = 0; + +function parsePositiveInteger(value: unknown): number | undefined { + if (typeof value === 'number') { + return Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined; + } + if (typeof value !== 'string' || value.trim() === '') { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined; +} + +function resolveCpuVoxelWorkerCount(override?: number): number { + if (override !== undefined) { + if (override === -1) { + return Math.max(1, availableParallelism() - 1); + } + return parsePositiveInteger(override) ?? Math.max(1, availableParallelism() - 1); + } + return ( + parsePositiveInteger(process.env.SPLAT_CPU_VOXEL_WORKERS) ?? + parsePositiveInteger(process.env.CPU_VOXEL_WORKERS) ?? + Math.max(1, availableParallelism() - 1) + ); +} + +interface CpuVoxelWorkerResult { + packed: ArrayBuffer; +} + +function toSharedFloat32(src: Float32Array): SharedArrayBuffer { + const sab = new SharedArrayBuffer(src.byteLength); + new Float32Array(sab).set(src); + return sab; +} + +function cpuVoxelizeSingleThread( + xCol: Float32Array, + yCol: Float32Array, + zCol: Float32Array, + sxCol: Float32Array, + syCol: Float32Array, + szCol: Float32Array, + qxCol: Float32Array, + qyCol: Float32Array, + qzCol: Float32Array, + qwCol: Float32Array, + aCol: Float32Array, + extents: Float32Array, + gridBounds: { min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }, + voxelResolution: number, + opacityCutoff: number, +): BlockMaskBuffer { + const nx = Math.max(4, Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution)); + const ny = Math.max(4, Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution)); + const nz = Math.max(4, Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution)); + const gridMinX = gridBounds.min.x; + const gridMinY = gridBounds.min.y; + const gridMinZ = gridBounds.min.z; + const cullMinX = gridBounds.min.x; + const cullMinY = gridBounds.min.y; + const cullMinZ = gridBounds.min.z; + const cullMaxX = gridBounds.max.x; + const cullMaxY = gridBounds.max.y; + const cullMaxZ = gridBounds.max.z; + const half = voxelResolution * 0.5; + const nBlockX = (nx + 3) >> 2; + const nBlockY = (ny + 3) >> 2; + const nBlockXY = nBlockX * nBlockY; + const opacityThreshold = Math.min(Math.max(opacityCutoff, 0), 1); + const blockMasks: Record = {}; + for (let i = 0; i < xCol.length; i++) { + const xi = xCol[i]; + const yi = yCol[i]; + const zi = zCol[i]; + const opacity = aCol[i]; + if (opacity <= 0) { + continue; + } + if (xi < cullMinX || xi > cullMaxX || yi < cullMinY || yi > cullMaxY || zi < cullMinZ || zi > cullMaxZ) { + continue; + } + + const maxD2 = + opacityThreshold <= 0 + ? Infinity + : opacity <= opacityThreshold + ? 0 + : -2 * Math.log(opacityThreshold / opacity); + if (maxD2 <= 0) { + continue; + } + const ex = extents[i * 3]; + const ey = extents[i * 3 + 1]; + const ez = extents[i * 3 + 2]; + const minIx = Math.max(0, Math.floor((xi - ex - gridMinX) / voxelResolution)); + const minIy = Math.max(0, Math.floor((yi - ey - gridMinY) / voxelResolution)); + const minIz = Math.max(0, Math.floor((zi - ez - gridMinZ) / voxelResolution)); + const maxIx = Math.min(nx - 1, Math.ceil((xi + ex - gridMinX) / voxelResolution)); + const maxIy = Math.min(ny - 1, Math.ceil((yi + ey - gridMinY) / voxelResolution)); + const maxIz = Math.min(nz - 1, Math.ceil((zi + ez - gridMinZ) / voxelResolution)); + if (minIx > maxIx || minIy > maxIy || minIz > maxIz) { + continue; + } + + const qx = qxCol[i]; + const qy = qyCol[i]; + const qz = qzCol[i]; + const qw = qwCol[i]; + // Input quaternions are already normalized. + const iqx = -qx; + const iqy = -qy; + const iqz = -qz; + const iqw = qw; + const isx = sxCol[i] > 1e-8 ? 1 / sxCol[i] : 1e8; + const isy = syCol[i] > 1e-8 ? 1 / syCol[i] : 1e8; + const isz = szCol[i] > 1e-8 ? 1 / szCol[i] : 1e8; + + for (let iz = minIz; iz <= maxIz; iz++) { + const vz = gridMinZ + (iz + 0.5) * voxelResolution; + for (let iy = minIy; iy <= maxIy; iy++) { + const vy = gridMinY + (iy + 0.5) * voxelResolution; + for (let ix = minIx; ix <= maxIx; ix++) { + const vx = gridMinX + (ix + 0.5) * voxelResolution; + const px = Math.min(Math.max(xi, vx - half), vx + half); + const py = Math.min(Math.max(yi, vy - half), vy + half); + const pz = Math.min(Math.max(zi, vz - half), vz + half); + const dx = px - xi; + const dy = py - yi; + const dz = pz - zi; + + const tx = 2 * (iqy * dz - iqz * dy); + const ty = 2 * (iqz * dx - iqx * dz); + const tz = 2 * (iqx * dy - iqy * dx); + const lx = dx + iqw * tx + (iqy * tz - iqz * ty); + const ly = dy + iqw * ty + (iqz * tx - iqx * tz); + const lz = dz + iqw * tz + (iqx * ty - iqy * tx); + + const sxv = lx * isx; + const syv = ly * isy; + const szv = lz * isz; + const d2 = sxv * sxv + syv * syv + szv * szv; + if (d2 > maxD2) { + continue; + } + + const blockX = ix >> 2; + const blockY = iy >> 2; + const blockZ = iz >> 2; + const blockLinear = blockX + blockY * nBlockX + blockZ * nBlockXY; + const bitIdx = (ix & 3) + ((iy & 3) << 2) + ((iz & 3) << 4); + const curr = blockMasks[blockLinear] ?? [0, 0]; + if (bitIdx < 32) { + curr[0] = (curr[0] | (1 << bitIdx)) >>> 0; + } else { + curr[1] = (curr[1] | (1 << (bitIdx - 32))) >>> 0; + } + blockMasks[blockLinear] = curr; + } + } + } + } + const output = new BlockMaskBuffer(); + for (const [blockLinearRaw, [lo, hi]] of Object.entries(blockMasks)) { + const blockLinear = Number(blockLinearRaw); + output.addBlock(blockLinear, lo, hi); + } + return output; +} + +export async function cpuVoxelize( + xCol: Float32Array, + yCol: Float32Array, + zCol: Float32Array, + sxCol: Float32Array, + syCol: Float32Array, + szCol: Float32Array, + qxCol: Float32Array, + qyCol: Float32Array, + qzCol: Float32Array, + qwCol: Float32Array, + aCol: Float32Array, + extents: Float32Array, + gridBounds: { min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }, + voxelResolution: number, + opacityCutoff: number, + options?: { + workerCount?: number; + alphaThreshold?: number; + }, +): Promise { + if (xCol.length < CPU_VOXEL_PARALLEL_MIN_GAUSSIANS) { + return cpuVoxelizeSingleThread( + xCol, + yCol, + zCol, + sxCol, + syCol, + szCol, + qxCol, + qyCol, + qzCol, + qwCol, + aCol, + extents, + gridBounds, + voxelResolution, + opacityCutoff, + ); + } + const nx = Math.max(4, Math.round((gridBounds.max.x - gridBounds.min.x) / voxelResolution)); + const ny = Math.max(4, Math.round((gridBounds.max.y - gridBounds.min.y) / voxelResolution)); + const nz = Math.max(4, Math.round((gridBounds.max.z - gridBounds.min.z) / voxelResolution)); + const gridMinX = gridBounds.min.x; + const gridMinY = gridBounds.min.y; + const gridMinZ = gridBounds.min.z; + if (xCol.length === 0) { + return new BlockMaskBuffer(); + } + const workers = Math.min(resolveCpuVoxelWorkerCount(options?.workerCount), xCol.length); + const nBlockX = (nx + 3) >> 2; + const nBlockY = (ny + 3) >> 2; + const nBlockXY = nBlockX * nBlockY; + const batchBlockSize = 4; + const numBatchX = Math.ceil(nBlockX / batchBlockSize); + const numBatchY = Math.ceil(nBlockY / batchBlockSize); + const numBatchZ = Math.ceil(Math.max(1, (nz + 3) >> 2) / batchBlockSize); + + const bvh = new GaussianBVH(xCol, yCol, zCol, extents); + const shared = { + xCol: toSharedFloat32(xCol), + yCol: toSharedFloat32(yCol), + zCol: toSharedFloat32(zCol), + sxCol: toSharedFloat32(sxCol), + syCol: toSharedFloat32(syCol), + szCol: toSharedFloat32(szCol), + qxCol: toSharedFloat32(qxCol), + qyCol: toSharedFloat32(qyCol), + qzCol: toSharedFloat32(qzCol), + qwCol: toSharedFloat32(qwCol), + aCol: toSharedFloat32(aCol), + extents: toSharedFloat32(extents), + }; + + try { + const output = new BlockMaskBuffer(); + let nextTaskId = 1; + const pool = Array.from({ length: workers }, (_v, slotId) => { + const worker = new Worker(new URL('../../workers/voxelize.js', import.meta.url), { + eval: true, + workerData: { + workerId: slotId, + voxelResolution, + opacityCutoff, + alphaThreshold: options?.alphaThreshold ?? ALPHA_THRESHOLD, + gridMinX, + gridMinY, + gridMinZ, + nBlockX, + nBlockY, + nBlockXY, + ...shared, + }, + }); + let currentResolve: ((v: CpuVoxelWorkerResult) => void) | undefined; + let currentReject: ((e: Error) => void) | undefined; + worker.on('message', message => { + if (message && typeof message === 'object' && 'packed' in (message as Record)) { + const typed = message as { packed: ArrayBuffer }; + if (!currentResolve) { + return; + } + const resolve = currentResolve; + currentResolve = undefined; + currentReject = undefined; + resolve({ packed: typed.packed }); + return; + } + if (!currentResolve) { + return; + } + const resolve = currentResolve; + currentResolve = undefined; + currentReject = undefined; + resolve({ packed: message as ArrayBuffer }); + }); + worker.on('error', error => { + currentReject?.(error); + currentResolve = undefined; + currentReject = undefined; + }); + worker.on('exit', code => { + if (code !== 0) { + currentReject?.(new Error(`cpu voxel worker exited with code ${code}`)); + currentResolve = undefined; + currentReject = undefined; + } + }); + function runTask(batchSpecs: Uint32Array, candidateIndices: Uint32Array): Promise { + return new Promise((resolve, reject) => { + if (currentResolve) { + reject(new Error(`cpu voxel worker ${slotId} received concurrent task`)); + return; + } + currentResolve = resolve; + currentReject = reject; + const taskId = nextTaskId++; + const batchSpecsBuffer = batchSpecs.buffer as ArrayBuffer; + const candidateIndicesBuffer = candidateIndices.buffer as ArrayBuffer; + worker.postMessage( + { + type: 'run', + taskId, + workerId: slotId, + batchSpecs: batchSpecsBuffer, + candidateIndices: candidateIndicesBuffer, + }, + [batchSpecsBuffer, candidateIndicesBuffer], + ); + }); + } + return { worker, runTask }; + }); + + function addPackedResult(buf: ArrayBuffer): void { + const packed = new Uint32Array(buf); + for (let i = 0; i < packed.length; i += 3) { + output.addBlock(packed[i]!, packed[i + 1]!, packed[i + 2]!); + } + } + + const availableSlots: Array> = pool.map((_slot, slotId) => Promise.resolve(slotId)); + async function dispatchTask(batchSpecs: Uint32Array, candidateIndices: Uint32Array): Promise { + const slotId = await Promise.race(availableSlots); + availableSlots[slotId] = pool[slotId].runTask(batchSpecs, candidateIndices).then(result => { + addPackedResult(result.packed); + return slotId; + }); + } + + const maxPendingBatches = 256; + const maxPendingIndices = 2 * 1024 * 1024; + const totalBlockZ = Math.max(1, (nz + 3) >> 2); + let pendingSpecs: number[] = []; + let pendingCandidates = new Uint32Array(Math.min(Math.max(1024, xCol.length), maxPendingIndices)); + let pendingCandidateCount = 0; + + function ensurePendingCandidateCapacity(needed: number): void { + if (needed <= pendingCandidates.length) { + return; + } + const next = new Uint32Array(Math.max(needed, pendingCandidates.length * 2)); + next.set(pendingCandidates.subarray(0, pendingCandidateCount)); + pendingCandidates = next; + } + + async function flushPendingTask(): Promise { + if (pendingSpecs.length === 0) { + return; + } + const batchSpecs = new Uint32Array(pendingSpecs); + const candidateIndices = pendingCandidates.slice(0, pendingCandidateCount); + pendingSpecs = []; + pendingCandidateCount = 0; + await dispatchTask(batchSpecs, candidateIndices); + } + + for (let bz = 0; bz < numBatchZ; bz++) { + for (let by = 0; by < numBatchY; by++) { + for (let bx = 0; bx < numBatchX; bx++) { + const blockX = bx * batchBlockSize; + const blockY = by * batchBlockSize; + const blockZ = bz * batchBlockSize; + const numBlocksX = Math.min(batchBlockSize, nBlockX - blockX); + const numBlocksY = Math.min(batchBlockSize, nBlockY - blockY); + const numBlocksZ = Math.min(batchBlockSize, totalBlockZ - blockZ); + if (numBlocksX <= 0 || numBlocksY <= 0 || numBlocksZ <= 0) { + continue; + } + + const minX = gridMinX + blockX * LEAF_SIZE * voxelResolution; + const minY = gridMinY + blockY * LEAF_SIZE * voxelResolution; + const minZ = gridMinZ + blockZ * LEAF_SIZE * voxelResolution; + const maxX = Math.min(gridBounds.max.x, minX + numBlocksX * LEAF_SIZE * voxelResolution); + const maxY = Math.min(gridBounds.max.y, minY + numBlocksY * LEAF_SIZE * voxelResolution); + const maxZ = Math.min(gridBounds.max.z, minZ + numBlocksZ * LEAF_SIZE * voxelResolution); + + let overlappingCount = bvh.queryOverlappingRawInto( + minX, + minY, + minZ, + maxX, + maxY, + maxZ, + pendingCandidates, + pendingCandidateCount, + ); + if (overlappingCount === 0) { + continue; + } + + if ( + pendingSpecs.length > 0 && + (pendingSpecs.length / 8 >= maxPendingBatches || + pendingCandidateCount + overlappingCount > maxPendingIndices) + ) { + await flushPendingTask(); + overlappingCount = bvh.queryOverlappingRawInto( + minX, + minY, + minZ, + maxX, + maxY, + maxZ, + pendingCandidates, + pendingCandidateCount, + ); + } + + const needed = pendingCandidateCount + overlappingCount; + if (needed > pendingCandidates.length) { + ensurePendingCandidateCapacity(needed); + overlappingCount = bvh.queryOverlappingRawInto( + minX, + minY, + minZ, + maxX, + maxY, + maxZ, + pendingCandidates, + pendingCandidateCount, + ); + } + + pendingSpecs.push( + blockX, + blockY, + blockZ, + numBlocksX, + numBlocksY, + numBlocksZ, + pendingCandidateCount, + overlappingCount, + ); + pendingCandidateCount += overlappingCount; + + if (pendingSpecs.length / 8 >= maxPendingBatches || pendingCandidateCount >= maxPendingIndices) { + await flushPendingTask(); + } + } + } + } + + await flushPendingTask(); + await Promise.all(availableSlots); + await Promise.all( + pool.map(async slot => { + slot.worker.postMessage({ type: 'shutdown' }); + await slot.worker.terminate(); + }), + ); + return output; + } catch (e) { + logger.warn( + `cpu voxel worker failed, using simplified single-thread fallback; ` + + `voxel result may differ from worker/GPU path: ${e instanceof Error ? e.message : String(e)}`, + ); + return cpuVoxelizeSingleThread( + xCol, + yCol, + zCol, + sxCol, + syCol, + szCol, + qxCol, + qyCol, + qzCol, + qwCol, + aCol, + extents, + gridBounds, + voxelResolution, + opacityCutoff, + ); + } +} + +/** + * GPU voxelization path using tiled multi-batch WGSL dispatch. + * Per-batch Gaussian indices are built on the GPU (count pass, CPU prefix sum, fill pass) into `indexBuffer`, + * replacing BVH `queryOverlappingRaw` on reference implementation. Batches are packed into mega-dispatches, then read back + * as per-block 64-bit masks to populate `BlockMaskBuffer`. + */ +export async function gpuVoxelize( + xCol: Float32Array, + yCol: Float32Array, + zCol: Float32Array, + sxCol: Float32Array, + syCol: Float32Array, + szCol: Float32Array, + qxCol: Float32Array, + qyCol: Float32Array, + qzCol: Float32Array, + qwCol: Float32Array, + aCol: Float32Array, + extents: Float32Array, + gridBounds: { min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }, + voxelResolution: number, + opacityCutoff: number, +): Promise { + const FLOATS_PER_GAUSSIAN = 16; + const UPLOAD_CHUNK_GAUSSIANS = 1 << 18; + const WORKGROUP_SIZE = 256; + // Tuning knobs: trade off submit overhead vs. peak memory/latency per mega-dispatch. + // Upstream caps 16^3-block batches at 256; with this port's 4^3-block + // batches, 16384 preserves the same max blocks per mega-dispatch. + const MEGA_MAX_BATCHES = 16384; + const MEGA_MAX_INDICES = 2 * 1024 * 1024; + const BATCH_SIZE = 4; + const MAX_BLOCKS_PER_BATCH = BATCH_SIZE * BATCH_SIZE * BATCH_SIZE; + + const blockSize = LEAF_SIZE * voxelResolution; + const numBlocksX = Math.round((gridBounds.max.x - gridBounds.min.x) / blockSize); + const numBlocksY = Math.round((gridBounds.max.y - gridBounds.min.y) / blockSize); + const numBlocksZ = Math.round((gridBounds.max.z - gridBounds.min.z) / blockSize); + const numBatchX = Math.ceil(numBlocksX / BATCH_SIZE); + const numBatchY = Math.ceil(numBlocksY / BATCH_SIZE); + const numBatchZ = Math.ceil(numBlocksZ / BATCH_SIZE); + const totalBatchCount = numBatchX * numBatchY * numBatchZ; + const gridMinX = gridBounds.min.x; + const gridMinY = gridBounds.min.y; + const gridMinZ = gridBounds.min.z; + const gaussianCount = xCol.length; + const batchWorldSize = blockSize * BATCH_SIZE; + const device = await getOrCreateDevice(); + const gaussianBufferBytes = gaussianCount * FLOATS_PER_GAUSSIAN * 4; + const maxBufferSize = Number(device.limits.maxBufferSize); + if (gaussianBufferBytes > maxBufferSize) { + throw new Error( + `gpuVoxelize: gaussian buffer size ${gaussianBufferBytes} exceeds device maxBufferSize ${maxBufferSize} ` + + `(gaussianCount=${gaussianCount}, bytesPerGaussian=${FLOATS_PER_GAUSSIAN * 4}).`, + ); + } + const batchCountPipeline = device.createComputePipeline({ + layout: 'auto', + compute: { module: device.createShaderModule({ code: buildPerBatchCountsWgsl() }), entryPoint: 'main' }, + }); + const batchFillPipeline = device.createComputePipeline({ + layout: 'auto', + compute: { module: device.createShaderModule({ code: fillPerBatchCandidatesWgsl() }), entryPoint: 'main' }, + }); + const voxelPipeline = device.createComputePipeline({ + layout: 'auto', + compute: { module: device.createShaderModule({ code: voxelizeMultiBatchWgsl() }), entryPoint: 'main' }, + }); + const blockBuffer = new BlockMaskBuffer(); + const gaussianBuffer = device.createBuffer({ + size: gaussianBufferBytes, + usage: GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST, + }); + const chunkRows = Math.min(gaussianCount, UPLOAD_CHUNK_GAUSSIANS); + const interleavedChunk = new Float32Array(chunkRows * FLOATS_PER_GAUSSIAN); + for (let chunkStart = 0; chunkStart < gaussianCount; chunkStart += chunkRows) { + const chunkCount = Math.min(chunkRows, gaussianCount - chunkStart); + for (let j = 0; j < chunkCount; j++) { + const i = chunkStart + j; + const offset = j * FLOATS_PER_GAUSSIAN; + interleavedChunk[offset + 0] = xCol[i]; + interleavedChunk[offset + 1] = yCol[i]; + interleavedChunk[offset + 2] = zCol[i]; + interleavedChunk[offset + 3] = aCol[i]; + + const rotW = qwCol[i]; + const rotX = qxCol[i]; + const rotY = qyCol[i]; + const rotZ = qzCol[i]; + const qlen = Math.sqrt(rotW * rotW + rotX * rotX + rotY * rotY + rotZ * rotZ); + const invLen = qlen > 0 ? 1 / qlen : 0; + interleavedChunk[offset + 4] = rotW * invLen; + interleavedChunk[offset + 5] = rotX * invLen; + interleavedChunk[offset + 6] = rotY * invLen; + interleavedChunk[offset + 7] = rotZ * invLen; + + interleavedChunk[offset + 8] = sxCol[i]; + interleavedChunk[offset + 9] = syCol[i]; + interleavedChunk[offset + 10] = szCol[i]; + interleavedChunk[offset + 11] = extents[i * 3 + 0]; + interleavedChunk[offset + 12] = extents[i * 3 + 1]; + interleavedChunk[offset + 13] = extents[i * 3 + 2]; + interleavedChunk[offset + 14] = 0; + interleavedChunk[offset + 15] = 0; + } + device.queue.writeBuffer( + gaussianBuffer, + chunkStart * FLOATS_PER_GAUSSIAN * 4, + interleavedChunk.buffer as ArrayBuffer, + 0, + chunkCount * FLOATS_PER_GAUSSIAN * 4, + ); + } + const batchUniformBuffer = device.createBuffer({ + size: 256, + usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST, + }); + const batchCountsBuffer = device.createBuffer({ + size: Math.max(4, totalBatchCount * 4), + usage: GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_COPY_SRC, + }); + const batchCountsReadBuffer = device.createBuffer({ + size: Math.max(4, totalBatchCount * 4), + usage: GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_MAP_READ, + }); + + const batchUniformRaw = new Uint32Array(16); + const batchUniformFloats = new Float32Array(batchUniformRaw.buffer); + batchUniformFloats[0] = gridMinX; + batchUniformFloats[1] = gridMinY; + batchUniformFloats[2] = gridMinZ; + batchUniformFloats[3] = batchWorldSize; + batchUniformRaw[4] = numBatchX; + batchUniformRaw[5] = numBatchY; + batchUniformRaw[6] = numBatchZ; + batchUniformRaw[7] = gaussianCount; + device.queue.writeBuffer(batchUniformBuffer, 0, batchUniformRaw.buffer, 0, 32); + + const countBindGroup = device.createBindGroup({ + layout: batchCountPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: batchUniformBuffer } }, + { binding: 1, resource: { buffer: gaussianBuffer } }, + { binding: 2, resource: { buffer: batchCountsBuffer } }, + ], + }); + const zeroBatchCounts = new Uint32Array(Math.max(1, totalBatchCount)); + device.queue.writeBuffer(batchCountsBuffer, 0, zeroBatchCounts); + // Count overlaps per coarse batch on GPU; copy out for CPU exclusive-prefix into batchCandidateOffsets. + { + const encoder = device.createCommandEncoder(); + const pass = encoder.beginComputePass(); + pass.setPipeline(batchCountPipeline); + pass.setBindGroup(0, countBindGroup); + pass.dispatchWorkgroups(Math.ceil(gaussianCount / WORKGROUP_SIZE), 1, 1); + pass.end(); + encoder.copyBufferToBuffer(batchCountsBuffer, 0, batchCountsReadBuffer, 0, totalBatchCount * 4); + device.queue.submit([encoder.finish()]); + } + await batchCountsReadBuffer.mapAsync(GPU_MAP_MODE_READ); + const countsMapped = new Uint32Array(batchCountsReadBuffer.getMappedRange()); + const batchCandidateCounts = new Uint32Array(totalBatchCount); + batchCandidateCounts.set(countsMapped.subarray(0, totalBatchCount)); + batchCountsReadBuffer.unmap(); + + const batchCandidateOffsets = new Uint32Array(totalBatchCount); + let totalCandidateCount = 0; + for (let i = 0; i < totalBatchCount; i++) { + batchCandidateOffsets[i] = totalCandidateCount; + totalCandidateCount += batchCandidateCounts[i]; + } + if (totalCandidateCount === 0) { + batchUniformBuffer.destroy(); + batchCountsBuffer.destroy(); + batchCountsReadBuffer.destroy(); + gaussianBuffer.destroy(); + return blockBuffer; + } + + const batchOffsetsBuffer = device.createBuffer({ + size: batchCandidateOffsets.byteLength, + usage: GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST, + }); + device.queue.writeBuffer(batchOffsetsBuffer, 0, batchCandidateOffsets); + const batchWriteHeadsBuffer = device.createBuffer({ + size: Math.max(4, totalBatchCount * 4), + usage: GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST, + }); + device.queue.writeBuffer(batchWriteHeadsBuffer, 0, zeroBatchCounts); + // Packed gaussian indices for all batches (size = totalCandidateCount); filled by GPU scatter pass. + const indexBuffer = device.createBuffer({ + size: totalCandidateCount * 4, + usage: GPU_BUFFER_USAGE_STORAGE, + }); + // GPU scatter pass: write gaussian indices into each batch segment of `indexBuffer`. + const fillBindGroup = device.createBindGroup({ + layout: batchFillPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: batchUniformBuffer } }, + { binding: 1, resource: { buffer: gaussianBuffer } }, + { binding: 2, resource: { buffer: batchOffsetsBuffer } }, + { binding: 3, resource: { buffer: batchWriteHeadsBuffer } }, + { binding: 4, resource: { buffer: indexBuffer } }, + ], + }); + { + const encoder = device.createCommandEncoder(); + const pass = encoder.beginComputePass(); + pass.setPipeline(batchFillPipeline); + pass.setBindGroup(0, fillBindGroup); + pass.dispatchWorkgroups(Math.ceil(gaussianCount / WORKGROUP_SIZE), 1, 1); + pass.end(); + device.queue.submit([encoder.finish()]); + } + + // BatchInfo struct in WGSL: 5xu32 + 3xf32 packed as 8xu32 per batch. + const BATCH_INFO_U32S = 8; + interface BatchSpec { + indexOffset: number; + indexCount: number; + blockMinX: number; + blockMinY: number; + blockMinZ: number; + numBlocksX: number; + numBlocksY: number; + numBlocksZ: number; + bx: number; + by: number; + bz: number; + } + interface SlotBuffers { + resultsBuffer: GPUBuffer; + readBuffer: GPUBuffer; + batchInfoBuffer: GPUBuffer; + uniformBuffer: GPUBuffer; + bindGroup: GPUBindGroup; + resultsBufferSize: number; + batchInfoCapacityBytes: number; + } + interface Inflight { + taskId: Promise<{ masks: Uint32Array; batches: BatchSpec[] }>; + } + + function createSlot(): SlotBuffers { + const uniformBuffer = device.createBuffer({ + size: 256, + usage: GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST, + }); + const resultsBuffer = device.createBuffer({ + size: MEGA_MAX_BATCHES * MAX_BLOCKS_PER_BATCH * 2 * 4, + usage: GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_SRC | GPU_BUFFER_USAGE_COPY_DST, + }); + const readBuffer = device.createBuffer({ + size: MEGA_MAX_BATCHES * MAX_BLOCKS_PER_BATCH * 2 * 4, + usage: GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_MAP_READ, + }); + const batchInfoBuffer = device.createBuffer({ + size: MEGA_MAX_BATCHES * BATCH_INFO_U32S * 4, + usage: GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST, + }); + const bindGroup = device.createBindGroup({ + layout: voxelPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: uniformBuffer } }, + { binding: 1, resource: { buffer: gaussianBuffer } }, + { binding: 2, resource: { buffer: indexBuffer } }, + { binding: 3, resource: { buffer: resultsBuffer } }, + { binding: 4, resource: { buffer: batchInfoBuffer } }, + ], + }); + return { + uniformBuffer, + resultsBuffer, + readBuffer, + batchInfoBuffer, + bindGroup, + resultsBufferSize: MEGA_MAX_BATCHES * MAX_BLOCKS_PER_BATCH * 2 * 4, + batchInfoCapacityBytes: MEGA_MAX_BATCHES * BATCH_INFO_U32S * 4, + }; + } + + const slots: SlotBuffers[] = [createSlot(), createSlot()]; + let currentSlot = 0; + let inflight: Inflight | undefined; + + function ensureSlotCapacity(slot: SlotBuffers, batchCount: number): void { + const resultBytes = Math.max(8, batchCount * MAX_BLOCKS_PER_BATCH * 2 * 4); + const batchInfoBytes = Math.max(32, batchCount * BATCH_INFO_U32S * 4); + if (resultBytes > slot.resultsBufferSize) { + slot.resultsBuffer.destroy(); + slot.readBuffer.destroy(); + // Growth (at least x2) avoids frequent GPU buffer reallocations when batch sizes fluctuate. + slot.resultsBufferSize = Math.max(slot.resultsBufferSize * 2, resultBytes); + slot.resultsBuffer = device.createBuffer({ + size: slot.resultsBufferSize, + usage: GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_SRC | GPU_BUFFER_USAGE_COPY_DST, + }); + slot.readBuffer = device.createBuffer({ + size: slot.resultsBufferSize, + usage: GPU_BUFFER_USAGE_COPY_DST | GPU_BUFFER_USAGE_MAP_READ, + }); + slot.bindGroup = device.createBindGroup({ + layout: voxelPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: slot.uniformBuffer } }, + { binding: 1, resource: { buffer: gaussianBuffer } }, + { binding: 2, resource: { buffer: indexBuffer } }, + { binding: 3, resource: { buffer: slot.resultsBuffer } }, + { binding: 4, resource: { buffer: slot.batchInfoBuffer } }, + ], + }); + } + if (batchInfoBytes > slot.batchInfoCapacityBytes) { + slot.batchInfoBuffer.destroy(); + // Same growth policy as results/read buffers. + slot.batchInfoCapacityBytes = Math.max(slot.batchInfoCapacityBytes * 2, batchInfoBytes); + slot.batchInfoBuffer = device.createBuffer({ + size: slot.batchInfoCapacityBytes, + usage: GPU_BUFFER_USAGE_STORAGE | GPU_BUFFER_USAGE_COPY_DST, + }); + slot.bindGroup = device.createBindGroup({ + layout: voxelPipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: slot.uniformBuffer } }, + { binding: 1, resource: { buffer: gaussianBuffer } }, + { binding: 2, resource: { buffer: indexBuffer } }, + { binding: 3, resource: { buffer: slot.resultsBuffer } }, + { binding: 4, resource: { buffer: slot.batchInfoBuffer } }, + ], + }); + } + } + + function processResults(masks: Uint32Array, batches: BatchSpec[]): void { + for (let b = 0; b < batches.length; b++) { + const batch = batches[b]; + const batchResultOffset = b * MAX_BLOCKS_PER_BATCH * 2; + const totalBatchBlocks = batch.numBlocksX * batch.numBlocksY * batch.numBlocksZ; + for (let blockIdx = 0; blockIdx < totalBatchBlocks; blockIdx++) { + const maskLo = masks[batchResultOffset + blockIdx * 2]; + const maskHi = masks[batchResultOffset + blockIdx * 2 + 1]; + if (maskLo === 0 && maskHi === 0) { + continue; + } + const localX = blockIdx % batch.numBlocksX; + const localY = Math.floor(blockIdx / batch.numBlocksX) % batch.numBlocksY; + const localZ = Math.floor(blockIdx / (batch.numBlocksX * batch.numBlocksY)); + const blockLinear = + batch.bx + + localX + + (batch.by + localY) * numBlocksX + + (batch.bz + localZ) * numBlocksX * numBlocksY; + blockBuffer.addBlock(blockLinear, maskLo, maskHi); + } + } + } + + let pendingBatches: BatchSpec[] = []; + let megaIndexSpan = 0; + + async function flushPendingBatches(): Promise { + if (pendingBatches.length === 0) { + return; + } + const submitSlot = currentSlot; + currentSlot = (currentSlot + 1) & 1; + const batchesToSubmit = pendingBatches; + pendingBatches = []; + megaIndexSpan = 0; + + const slot = slots[submitSlot]; + ensureSlotCapacity(slot, batchesToSubmit.length); + + const resultsU32Count = batchesToSubmit.length * MAX_BLOCKS_PER_BATCH * 2; + + const batchInfoU32Count = batchesToSubmit.length * BATCH_INFO_U32S; + const batchInfoF32 = new Float32Array(batchInfoU32Count); + const batchInfoU32 = new Uint32Array(batchInfoF32.buffer); + for (let i = 0; i < batchesToSubmit.length; i++) { + const batch = batchesToSubmit[i]; + const base = i * BATCH_INFO_U32S; + batchInfoU32[base + 0] = batch.indexOffset; + batchInfoU32[base + 1] = batch.indexCount; + batchInfoU32[base + 2] = batch.numBlocksX; + batchInfoU32[base + 3] = batch.numBlocksY; + batchInfoU32[base + 4] = batch.numBlocksZ; + batchInfoF32[base + 5] = batch.blockMinX; + batchInfoF32[base + 6] = batch.blockMinY; + batchInfoF32[base + 7] = batch.blockMinZ; + } + device.queue.writeBuffer( + slot.batchInfoBuffer, + 0, + batchInfoU32.buffer as ArrayBuffer, + batchInfoU32.byteOffset, + batchInfoU32.byteLength, + ); + + const uniform = new Uint32Array(16); + const uf = new Float32Array(uniform.buffer); + uf[0] = opacityCutoff; + uf[1] = voxelResolution; + uniform[2] = MAX_BLOCKS_PER_BATCH; + device.queue.writeBuffer(slot.uniformBuffer, 0, uniform.buffer, 0, 12); + + const encoder = device.createCommandEncoder(); + encoder.clearBuffer(slot.resultsBuffer, 0, resultsU32Count * 4); + const pass = encoder.beginComputePass(); + pass.setPipeline(voxelPipeline); + pass.setBindGroup(0, slot.bindGroup); + pass.dispatchWorkgroups(MAX_BLOCKS_PER_BATCH, 1, batchesToSubmit.length); + pass.end(); + encoder.copyBufferToBuffer(slot.resultsBuffer, 0, slot.readBuffer, 0, resultsU32Count * 4); + device.queue.submit([encoder.finish()]); + + const taskPromise = (async () => { + await slot.readBuffer.mapAsync(GPU_MAP_MODE_READ); + const mapped = new Uint32Array(slot.readBuffer.getMappedRange()); + const copied = new Uint32Array(resultsU32Count); + copied.set(mapped.subarray(0, resultsU32Count)); + slot.readBuffer.unmap(); + return { masks: copied, batches: batchesToSubmit }; + })(); + + if (inflight) { + const done = await inflight.taskId; + processResults(done.masks, done.batches); + } + inflight = { taskId: taskPromise }; + } + + for (let bz = 0; bz < numBatchZ; bz++) { + for (let by = 0; by < numBatchY; by++) { + for (let bx = 0; bx < numBatchX; bx++) { + const batchId = bz * numBatchX * numBatchY + by * numBatchX + bx; + const indexCount = batchCandidateCounts[batchId]; + if (indexCount === 0) { + continue; + } + const bxBlock = bx * BATCH_SIZE; + const byBlock = by * BATCH_SIZE; + const bzBlock = bz * BATCH_SIZE; + const currBatchX = Math.min(BATCH_SIZE, numBlocksX - bxBlock); + const currBatchY = Math.min(BATCH_SIZE, numBlocksY - byBlock); + const currBatchZ = Math.min(BATCH_SIZE, numBlocksZ - bzBlock); + // World-space origin of this batch's 16^3 block grid; indexOffset/indexCount refer to `indexBuffer`. + const blockMinX = gridMinX + bxBlock * blockSize; + const blockMinY = gridMinY + byBlock * blockSize; + const blockMinZ = gridMinZ + bzBlock * blockSize; + pendingBatches.push({ + indexOffset: batchCandidateOffsets[batchId], + indexCount, + blockMinX, + blockMinY, + blockMinZ, + numBlocksX: currBatchX, + numBlocksY: currBatchY, + numBlocksZ: currBatchZ, + bx: bxBlock, + by: byBlock, + bz: bzBlock, + }); + megaIndexSpan += indexCount; + if (pendingBatches.length >= MEGA_MAX_BATCHES || megaIndexSpan >= MEGA_MAX_INDICES) { + await flushPendingBatches(); + } + } + } + } + await flushPendingBatches(); + if (inflight) { + const done = await inflight.taskId; + processResults(done.masks, done.batches); + } + batchUniformBuffer.destroy(); + batchCountsBuffer.destroy(); + batchCountsReadBuffer.destroy(); + batchOffsetsBuffer.destroy(); + batchWriteHeadsBuffer.destroy(); + indexBuffer.destroy(); + for (const slot of slots) { + slot.uniformBuffer.destroy(); + slot.resultsBuffer.destroy(); + slot.readBuffer.destroy(); + slot.batchInfoBuffer.destroy(); + } + gaussianBuffer.destroy(); + return blockBuffer; +} diff --git a/packages/splat-transform/src/utils/webgpu.ts b/packages/splat-transform/src/utils/webgpu.ts new file mode 100644 index 0000000..b256aa6 --- /dev/null +++ b/packages/splat-transform/src/utils/webgpu.ts @@ -0,0 +1,147 @@ +/// +import { createRequire } from 'node:module'; +import { logger } from './index.js'; +import { getNativePackageName } from '../native/utils.js'; + +interface DawnModule { + create(options: string[]): GPU; + globals: object; +} + +const getModule = (function () { + let m: DawnModule | undefined = undefined; + return function () { + if (!m) { + m = createRequire(import.meta.url)(getNativePackageName() + '/dawn.node') as DawnModule; + Object.assign(globalThis, m.globals); + } + return m; + }; +})(); + +let gpu: GPU | undefined = undefined; + +// Get Dawn's actual adapter names by triggering its error message. +// This is the official documented method for enumerating adapters: +// https://github.com/dawn-gpu/node-webgpu?tab=readme-ov-file#usage +async function getDawnAdapterNames() { + try { + const gpu = getModule().create(['adapter=__list_adapters__']); + await gpu.requestAdapter(); + } catch (e) { + // Parse Dawn's error message to extract adapter names + const message = e instanceof Error ? e.message : String(e); + const lines = message.split('\n'); + const names: string[] = []; + + for (const line of lines) { + // Look for lines like: " * backend: 'd3d12', name: 'NVIDIA RTX A2000 8GB Laptop GPU'" + const match = line.match(/name:\s*'([^']+)'/); + if (match) { + names.push(match[1]); + } + } + + return names; + } + + // Unexpected: requestAdapter should have thrown with invalid adapter name + logger.warn('Expected adapter enumeration to throw an error, but it did not.'); + return []; +} + +// Cache enumerated adapters so we don't query Dawn multiple times +let cachedAdapters: Array<{ index: number; name: string }> | null = null; +export async function enumerateAdapters() { + if (cachedAdapters) { + return cachedAdapters; + } + + try { + logger.info('Detecting GPU adapters...'); + + // Get the actual adapter names directly from Dawn + const dawnAdapterNames = await getDawnAdapterNames(); + + // Cache and return the list + cachedAdapters = dawnAdapterNames.map((name, index) => ({ + index, + name, + })); + + return cachedAdapters; + } catch (e) { + logger.error('Failed to enumerate adapters. Error:'); + logger.error(e as any); + logger.error('\nThis usually means WebGPU is not available. Please ensure:'); + logger.error(' - Your GPU drivers are up to date'); + logger.error(' - Your GPU supports Vulkan, D3D12, or Metal'); + return []; + } +} + +export function initGPUAdapter(options: string[] = []) { + if (!gpu) { + logger.info(`Init WebGPU adapter${options.length > 0 ? ` with [${options.join(';')}]` : '.'}`); + gpu = getModule().create(options); + } +} + +export async function createDevice() { + initGPUAdapter(); + const adapter = await gpu!.requestAdapter({ + powerPreference: 'high-performance', + }); + if (!adapter) { + throw new Error(`No available WebGPU adapter found.`); + } + const device = await adapter.requestDevice({ + requiredFeatures: Array.from(adapter.features) as GPUFeatureName[], + requiredLimits: adapter.limits as any, + }); + + if (!device) { + throw new Error('Create WebGPU device failed.'); + } + + logger.info( + `WebGPU device created: ${device.adapterInfo.vendor}, ${device.adapterInfo.device}, ${device.adapterInfo.description}`, + ); + + device.addEventListener('uncapturederror', event => { + const error = event.error as Error & { type?: string }; + const type = error?.type ? ` (${error.type})` : ''; + logger.error(`WebGPU uncaptured error${type}: ${error?.message ?? String(error)}`); + }); + device.lost + .then(info => { + const message = info.message ? `, message=${info.message}` : ''; + if (info.reason === 'destroyed') { + return; + } + logger.warn(`WebGPU device lost unexpectedly: reason=${info.reason}${message}`); + }) + .catch(e => { + logger.error(`WebGPU device lost handler failed: ${e instanceof Error ? e.message : String(e)}`); + }); + + return device; +} + +const { getOrCreateDevice, releaseSharedDevice } = (function () { + let device: GPUDevice | undefined = undefined; + return { + async getOrCreateDevice() { + if (!device) { + device = await createDevice(); + } + return device; + }, + releaseSharedDevice() { + device?.destroy(); + device = undefined; + }, + }; +})(); + +export { getOrCreateDevice, releaseSharedDevice }; diff --git a/packages/splat-transform/src/utils/worker.ts b/packages/splat-transform/src/utils/worker.ts new file mode 100644 index 0000000..4826264 --- /dev/null +++ b/packages/splat-transform/src/utils/worker.ts @@ -0,0 +1,217 @@ +import type { Worker } from 'node:worker_threads'; + +export type PooledWorker = Worker & { + readonly alive: boolean; + readonly busy: boolean; + readonly permanent: boolean; + release(): void; +}; + +interface WorkerSlot { + worker: PooledWorker; + alive: boolean; + busy: boolean; + permanent: boolean; +} + +interface WorkerRequest { + resolve: (worker: PooledWorker) => void; + reject: (reason: unknown) => void; +} + +export class WorkerPool { + private slots: WorkerSlot[] = []; + private requests: WorkerRequest[] = []; + private cleanupTimer: NodeJS.Timeout | undefined = undefined; + private disposed = false; + + readonly maxWorkerCount: number; + readonly permanentWorkers: number; + readonly cleanupTimeout: number; + + constructor( + readonly name: string, + private readonly createWorker: () => Worker, + maxWorkerCount = 1, + permanentWorkers = 0, + cleanupTimeout = 30000, + ) { + this.maxWorkerCount = Math.max(1, Math.floor(maxWorkerCount)); + this.permanentWorkers = Math.min(this.maxWorkerCount, Math.max(0, Math.floor(permanentWorkers))); + this.cleanupTimeout = Math.max(0, cleanupTimeout); + } + + get workerCount(): number { + return this.slots.length; + } + + get pendingTaskCount(): number { + return this.requests.length; + } + + private createSlot(): WorkerSlot { + const slot: WorkerSlot = { + worker: this.createWorker() as PooledWorker, + alive: true, + busy: false, + permanent: this.slots.length < this.permanentWorkers, + }; + + Object.defineProperties(slot.worker, { + alive: { + get: () => slot.alive, + enumerable: true, + configurable: false, + }, + busy: { + get: () => slot.busy, + enumerable: true, + configurable: false, + }, + permanent: { + get: () => slot.permanent, + enumerable: true, + configurable: false, + }, + release: { + value: () => { + if (!slot.busy) { + return; + } + slot.busy = false; + this.flush(); + }, + enumerable: false, + configurable: false, + }, + }); + + slot.worker.once('exit', () => { + slot.alive = false; + slot.busy = false; + const index = this.slots.indexOf(slot); + if (index >= 0) { + this.slots.splice(index, 1); + } + if (!this.disposed) { + this.flush(); + } + }); + + this.slots.push(slot); + return slot; + } + + private takeSlot(): WorkerSlot | undefined { + const slot = this.slots.find(s => s.alive && !s.busy); + if (slot) { + slot.busy = true; + return slot; + } + if (this.slots.length < this.maxWorkerCount) { + const created = this.createSlot(); + created.busy = true; + return created; + } + return undefined; + } + + private scheduleCleanup(): void { + if (this.cleanupTimer != null || this.slots.length <= this.permanentWorkers) { + return; + } + this.cleanupTimer = globalThis.setTimeout(() => { + this.cleanupIdleWorkers(); + }, this.cleanupTimeout); + } + + private cleanupIdleWorkers(): void { + this.cleanupTimer = undefined; + if (this.disposed || this.slots.length <= this.permanentWorkers) { + return; + } + + const slots = this.slots; + this.slots = []; + for (const slot of slots) { + if (!slot.alive) { + continue; + } + if (slot.busy || slot.permanent) { + this.slots.push(slot); + continue; + } + slot.alive = false; + void slot.worker.terminate(); + } + } + + flush(): void { + if (this.disposed) { + return; + } + + while (this.requests.length > 0) { + const slot = this.takeSlot(); + if (!slot) { + return; + } + this.requests.shift()!.resolve(slot.worker); + } + + this.scheduleCleanup(); + } + + getWorker(): Promise { + if (this.disposed) { + return Promise.reject(new Error(`Worker pool "${this.name}" has been disposed`)); + } + if (this.cleanupTimer != null) { + globalThis.clearTimeout(this.cleanupTimer); + this.cleanupTimer = undefined; + } + + const slot = this.takeSlot(); + if (slot) { + return Promise.resolve(slot.worker); + } + + return new Promise((resolve, reject) => { + this.requests.push({ resolve, reject }); + }); + } + + async using(fn: (worker: PooledWorker) => T | Promise): Promise { + const worker = await this.getWorker(); + try { + return await fn(worker); + } finally { + worker.release(); + } + } + + async dispose(): Promise { + if (this.disposed) { + return; + } + this.disposed = true; + if (this.cleanupTimer != null) { + globalThis.clearTimeout(this.cleanupTimer); + this.cleanupTimer = undefined; + } + + const reason = new Error(`Worker pool "${this.name}" has been disposed`); + for (const request of this.requests.splice(0)) { + request.reject(reason); + } + + const slots = this.slots.splice(0); + await Promise.allSettled( + slots.map(slot => { + slot.alive = false; + slot.busy = false; + return slot.worker.terminate(); + }), + ); + } +} diff --git a/packages/splat-transform/src/workers/voxelize.ts b/packages/splat-transform/src/workers/voxelize.ts new file mode 100644 index 0000000..efea180 --- /dev/null +++ b/packages/splat-transform/src/workers/voxelize.ts @@ -0,0 +1,190 @@ +import { parentPort, workerData } from 'node:worker_threads'; + +if (!parentPort) { + throw new Error('worker must run inside worker_threads'); +} + +const { + voxelResolution, + opacityCutoff, + alphaThreshold, + gridMinX, + gridMinY, + gridMinZ, + nBlockX, + // nBlockY, + nBlockXY, + xCol, + yCol, + zCol, + sxCol, + syCol, + szCol, + qxCol, + qyCol, + qzCol, + qwCol, + aCol, + extents, +} = workerData; +const x = new Float32Array(xCol); +const y = new Float32Array(yCol); +const z = new Float32Array(zCol); +const sx = new Float32Array(sxCol); +const sy = new Float32Array(syCol); +const sz = new Float32Array(szCol); +const qx = new Float32Array(qxCol); +const qy = new Float32Array(qyCol); +const qz = new Float32Array(qzCol); +const qw = new Float32Array(qwCol); +const a = new Float32Array(aCol); +const ext = new Float32Array(extents); +const half = voxelResolution * 0.5; +const sigmaCutoff = opacityCutoff <= 0 ? 0 : -Math.log1p(-Math.min(opacityCutoff, 1 - 1e-8)); +const SPEC_STRIDE = 8; +const BATCH_BLOCK_SIZE = 4; +const BATCH_VOXEL_SIZE = BATCH_BLOCK_SIZE * 4; +const MAX_BATCH_VOXELS = BATCH_VOXEL_SIZE * BATCH_VOXEL_SIZE * BATCH_VOXEL_SIZE; +const MAX_BATCH_BLOCKS = BATCH_BLOCK_SIZE * BATCH_BLOCK_SIZE * BATCH_BLOCK_SIZE; +const sigmaBuffer = new Float32Array(MAX_BATCH_VOXELS); +const solidBuffer = new Uint8Array(MAX_BATCH_VOXELS); +const masksLoBuffer = new Uint32Array(MAX_BATCH_BLOCKS); +const masksHiBuffer = new Uint32Array(MAX_BATCH_BLOCKS); + +function runBatchSet(batchSpecs: any, candidateIndices: any) { + const specs = new Uint32Array(batchSpecs); + const candidates = new Uint32Array(candidateIndices); + const packedBlocks = []; + const batchCount = specs.length / SPEC_STRIDE; + for (let specIdx = 0; specIdx < batchCount; specIdx++) { + const specBase = specIdx * SPEC_STRIDE; + const batchBlockX = specs[specBase + 0]; + const batchBlockY = specs[specBase + 1]; + const batchBlockZ = specs[specBase + 2]; + const numBlocksX = specs[specBase + 3]; + const numBlocksY = specs[specBase + 4]; + const numBlocksZ = specs[specBase + 5]; + const indexOffset = specs[specBase + 6]; + const indexCount = specs[specBase + 7]; + const numVoxelsX = numBlocksX * 4; + const numVoxelsY = numBlocksY * 4; + const numVoxelsZ = numBlocksZ * 4; + const totalVoxels = numVoxelsX * numVoxelsY * numVoxelsZ; + const totalBlocks = numBlocksX * numBlocksY * numBlocksZ; + sigmaBuffer.fill(0, 0, totalVoxels); + solidBuffer.fill(0, 0, totalVoxels); + masksLoBuffer.fill(0, 0, totalBlocks); + masksHiBuffer.fill(0, 0, totalBlocks); + const batchMinVoxelX = batchBlockX * 4; + const batchMinVoxelY = batchBlockY * 4; + const batchMinVoxelZ = batchBlockZ * 4; + const batchMaxVoxelX = batchMinVoxelX + numVoxelsX - 1; + const batchMaxVoxelY = batchMinVoxelY + numVoxelsY - 1; + const batchMaxVoxelZ = batchMinVoxelZ + numVoxelsZ - 1; + for (let c = 0; c < indexCount; c++) { + const i = candidates[indexOffset + c]; + const xi = x[i]; + const yi = y[i]; + const zi = z[i]; + const opacity = a[i]; + if (opacity <= 0) continue; + const maxContributionD2 = + alphaThreshold <= 0 + ? Infinity + : opacity <= alphaThreshold + ? 0 + : -2 * Math.log(alphaThreshold / opacity); + if (maxContributionD2 <= 0) continue; + const ex = ext[i * 3]; + const ey = ext[i * 3 + 1]; + const ez = ext[i * 3 + 2]; + const minIx = Math.max(batchMinVoxelX, Math.floor((xi - ex - gridMinX) / voxelResolution)); + const minIy = Math.max(batchMinVoxelY, Math.floor((yi - ey - gridMinY) / voxelResolution)); + const minIz = Math.max(batchMinVoxelZ, Math.floor((zi - ez - gridMinZ) / voxelResolution)); + const maxIx = Math.min(batchMaxVoxelX, Math.ceil((xi + ex - gridMinX) / voxelResolution)); + const maxIy = Math.min(batchMaxVoxelY, Math.ceil((yi + ey - gridMinY) / voxelResolution)); + const maxIz = Math.min(batchMaxVoxelZ, Math.ceil((zi + ez - gridMinZ) / voxelResolution)); + if (minIx > maxIx || minIy > maxIy || minIz > maxIz) continue; + const iqx = -qx[i], + iqy = -qy[i], + iqz = -qz[i], + iqw = qw[i]; + const isx = sx[i] > 1e-8 ? 1 / sx[i] : 1e8; + const isy = sy[i] > 1e-8 ? 1 / sy[i] : 1e8; + const isz = sz[i] > 1e-8 ? 1 / sz[i] : 1e8; + for (let iz = minIz; iz <= maxIz; iz++) { + const localZ = iz - batchMinVoxelZ; + const vz = gridMinZ + (iz + 0.5) * voxelResolution; + for (let iy = minIy; iy <= maxIy; iy++) { + const localY = iy - batchMinVoxelY; + const vy = gridMinY + (iy + 0.5) * voxelResolution; + for (let ix = minIx; ix <= maxIx; ix++) { + const localX = ix - batchMinVoxelX; + const localIndex = localX + localY * numVoxelsX + localZ * numVoxelsX * numVoxelsY; + if (solidBuffer[localIndex]) continue; + const vx = gridMinX + (ix + 0.5) * voxelResolution; + const px = Math.min(Math.max(xi, vx - half), vx + half); + const py = Math.min(Math.max(yi, vy - half), vy + half); + const pz = Math.min(Math.max(zi, vz - half), vz + half); + const dx = px - xi; + const dy = py - yi; + const dz = pz - zi; + const tx = 2 * (iqy * dz - iqz * dy); + const ty = 2 * (iqz * dx - iqx * dz); + const tz = 2 * (iqx * dy - iqy * dx); + const lx = dx + iqw * tx + (iqy * tz - iqz * ty); + const ly = dy + iqw * ty + (iqz * tx - iqx * tz); + const lz = dz + iqw * tz + (iqx * ty - iqy * tx); + const sxv = lx * isx; + const syv = ly * isy; + const szv = lz * isz; + const d2 = sxv * sxv + syv * syv + szv * szv; + if (d2 > maxContributionD2) continue; + const contribution = opacity * Math.exp(-0.5 * d2); + if (contribution <= 0) continue; + const total = sigmaBuffer[localIndex] + contribution; + sigmaBuffer[localIndex] = total; + if (total < sigmaCutoff) continue; + solidBuffer[localIndex] = 1; + const localBlockX = localX >> 2; + const localBlockY = localY >> 2; + const localBlockZ = localZ >> 2; + const localBlock = + localBlockX + localBlockY * numBlocksX + localBlockZ * numBlocksX * numBlocksY; + const bitIdx = (localX & 3) + ((localY & 3) << 2) + ((localZ & 3) << 4); + if (bitIdx < 32) masksLoBuffer[localBlock] = (masksLoBuffer[localBlock] | (1 << bitIdx)) >>> 0; + else masksHiBuffer[localBlock] = (masksHiBuffer[localBlock] | (1 << (bitIdx - 32))) >>> 0; + } + } + } + } + for (let localBlock = 0; localBlock < totalBlocks; localBlock++) { + const lo = masksLoBuffer[localBlock]; + const hi = masksHiBuffer[localBlock]; + if ((lo | hi) === 0) continue; + const localBlockX = localBlock % numBlocksX; + const localBlockY = ((localBlock / numBlocksX) | 0) % numBlocksY; + const localBlockZ = (localBlock / (numBlocksX * numBlocksY)) | 0; + const blockIdx = + batchBlockX + + localBlockX + + (batchBlockY + localBlockY) * nBlockX + + (batchBlockZ + localBlockZ) * nBlockXY; + packedBlocks.push(blockIdx >>> 0, lo >>> 0, hi >>> 0); + } + } + const packed = new Uint32Array(packedBlocks.length); + packed.set(packedBlocks); + return packed.buffer; +} + +parentPort.on('message', msg => { + if (!msg || typeof msg !== 'object') return; + if (msg.type === 'shutdown') { + process.exit(0); + } + if (msg.type !== 'run') return; + const taskId = msg.taskId; + const packed = runBatchSet(msg.batchSpecs, msg.candidateIndices); + parentPort!.postMessage({ taskId, packed }, [packed]); +}); diff --git a/packages/splat-transform/src/workers/write.ts b/packages/splat-transform/src/workers/write.ts new file mode 100644 index 0000000..5b0a62f --- /dev/null +++ b/packages/splat-transform/src/workers/write.ts @@ -0,0 +1,28 @@ +import { parentPort } from 'node:worker_threads'; +import { writeSplatFile, type ISplatData } from '../utils/splat.js'; +import { SplatData } from '../SplatData.js'; +import { logger } from '../utils/index.js'; + +if (!parentPort) { + throw new Error('worker must run inside worker_threads'); +} + +logger.silent = true; + +parentPort.on('message', async e => { + try { + const { filepath, data, enableMortonSort, compressLevel, spzVersion } = e as { + filepath: string; + data: ISplatData; + enableMortonSort: boolean; + compressLevel?: number; + spzVersion?: number; + }; + const splatData = new SplatData(); + splatData.deserialize(data); + await writeSplatFile(filepath, splatData, enableMortonSort, compressLevel, spzVersion); + parentPort!.postMessage({ success: true, content: '' }); + } catch (e) { + parentPort!.postMessage({ success: false, content: e.toString() }); + } +}); diff --git a/packages/splat-transform/tsconfig.json b/packages/splat-transform/tsconfig.json new file mode 100644 index 0000000..5b9f4c3 --- /dev/null +++ b/packages/splat-transform/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@internal/utils/tsconfig.json", + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "moduleResolution": "nodenext", + "resolveJsonModule": true, + "strictPropertyInitialization": false, + "rootDir": "./src", + "outDir": "./dist", + "sourceMap": false, + "types": ["node"], + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true + }, + "include": ["./src"], + "exclude": ["**/dist", "node_modules/**"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d49846a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5918 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@internal/utils': workspace:* + '@qunhe/egs-typings': workspace:* + '@qunhe/egs-lib': workspace:* + '@qunhe/egs': workspace:* + '@qunhe/egs-animation': workspace:* + '@qunhe/egs-texture-loader': workspace:* + '@qunhe/egs-gltf-loader': workspace:* + '@qunhe/egs-draco-loader': workspace:* + '@qunhe/egs-splat-loader': workspace:* + '@qunhe/egs-splat-utils': workspace:* + '@manycore/aholo-splat-transform': workspace:* + '@manycore/aholo-splat-dev-server': workspace:* + '@manycore/aholo-splat-transform-linux-arm64-gnu': workspace:* + '@manycore/aholo-splat-transform-linux-x64-gnu': workspace:* + '@manycore/aholo-splat-transform-win32-arm64-msvc': workspace:* + '@manycore/aholo-splat-transform-win32-x64-msvc': workspace:* + '@manycore/aholo-splat-transform-darwin-arm64': workspace:* + '@types/node': ^22.20.0 + typescript: ^6.0.3 + esbuild: ^0.28.1 + +importers: + + .: + devDependencies: + '@internal/utils': + specifier: workspace:* + version: link:external/egs-core/tools/utils + '@manycore/aholo-splat-dev-server': + specifier: workspace:* + version: link:external/splat-dev-server + '@manycore/aholo-splat-transform': + specifier: workspace:* + version: link:packages/splat-transform + husky: + specifier: ^9.1.7 + version: 9.1.7 + lint-staged: + specifier: ^17.0.8 + version: 17.0.8 + oxfmt: + specifier: ^0.58.0 + version: 0.58.0 + oxlint: + specifier: ^1.73.0 + version: 1.73.0 + typedoc: + specifier: ^0.28.20 + version: 0.28.20(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + external/egs-core/packages/egs: + dependencies: + '@qunhe/egs-lib': + specifier: workspace:* + version: link:../egs-lib + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + '@qunhe/egs-typings': + specifier: workspace:* + version: link:../egs-typings + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + external/egs-core/packages/egs-animation: + dependencies: + '@qunhe/egs': + specifier: workspace:* + version: link:../egs + semver: + specifier: ^7.8.5 + version: 7.8.5 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + external/egs-core/packages/egs-lib: + dependencies: + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + external/egs-core/packages/egs-typings: + dependencies: + '@types/webxr': + specifier: ^0.5.24 + version: 0.5.24 + '@webgpu/types': + specifier: ^0.1.71 + version: 0.1.71 + + external/egs-core/packages/loaders/draco-loader: + dependencies: + '@qunhe/egs-lib': + specifier: workspace:* + version: link:../../egs-lib + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + external/egs-core/packages/loaders/gltf-loader: + dependencies: + '@qunhe/egs': + specifier: workspace:* + version: link:../../egs + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + external/egs-core/packages/loaders/splat-loader: + dependencies: + '@qunhe/egs-lib': + specifier: workspace:* + version: link:../../egs-lib + fflate: + specifier: ^0.8.3 + version: 0.8.3 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + external/egs-core/packages/loaders/texture-loader: + dependencies: + '@qunhe/egs': + specifier: workspace:* + version: link:../../egs + '@qunhe/egs-lib': + specifier: workspace:* + version: link:../../egs-lib + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + external/egs-core/packages/utils/splat-utils: + dependencies: + '@qunhe/egs': + specifier: workspace:* + version: link:../../egs + '@qunhe/egs-lib': + specifier: workspace:* + version: link:../../egs-lib + '@qunhe/egs-splat-loader': + specifier: workspace:* + version: link:../../loaders/splat-loader + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + external/egs-core/tools/utils: + dependencies: + '@microsoft/api-extractor': + specifier: ^7.58.9 + version: 7.58.9(@types/node@22.20.0) + chalk: + specifier: ^5.6.2 + version: 5.6.2 + glob: + specifier: ^13.0.6 + version: 13.0.6 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + external/splat-dev-server: + dependencies: + express: + specifier: ^5.2.1 + version: 5.2.1 + yargs: + specifier: ^18.0.0 + version: 18.0.0 + + packages/renderer: + devDependencies: + '@qunhe/egs': + specifier: workspace:* + version: link:../../external/egs-core/packages/egs + '@qunhe/egs-animation': + specifier: workspace:* + version: link:../../external/egs-core/packages/egs-animation + '@qunhe/egs-draco-loader': + specifier: workspace:* + version: link:../../external/egs-core/packages/loaders/draco-loader + '@qunhe/egs-gltf-loader': + specifier: workspace:* + version: link:../../external/egs-core/packages/loaders/gltf-loader + '@qunhe/egs-lib': + specifier: workspace:* + version: link:../../external/egs-core/packages/egs-lib + '@qunhe/egs-splat-loader': + specifier: workspace:* + version: link:../../external/egs-core/packages/loaders/splat-loader + '@qunhe/egs-splat-utils': + specifier: workspace:* + version: link:../../external/egs-core/packages/utils/splat-utils + '@qunhe/egs-texture-loader': + specifier: workspace:* + version: link:../../external/egs-core/packages/loaders/texture-loader + '@types/node': + specifier: ^22.20.0 + version: 22.20.0 + esbuild: + specifier: ^0.28.1 + version: 0.28.1 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + packages/splat-transform: + dependencies: + fflate: + specifier: ^0.8.3 + version: 0.8.3 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + yargs: + specifier: ^18.0.0 + version: 18.0.0 + devDependencies: + '@types/node': + specifier: ^22.20.0 + version: 22.20.0 + '@webgpu/types': + specifier: ^0.1.71 + version: 0.1.71 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + optionalDependencies: + '@manycore/aholo-splat-transform-darwin-arm64': + specifier: workspace:* + version: link:../splat-transform-native/splat-transform-darwin-arm64 + '@manycore/aholo-splat-transform-linux-arm64-gnu': + specifier: workspace:* + version: link:../splat-transform-native/splat-transform-linux-arm64-gnu + '@manycore/aholo-splat-transform-linux-x64-gnu': + specifier: workspace:* + version: link:../splat-transform-native/splat-transform-linux-x64-gnu + '@manycore/aholo-splat-transform-win32-arm64-msvc': + specifier: workspace:* + version: link:../splat-transform-native/splat-transform-win32-arm64-msvc + '@manycore/aholo-splat-transform-win32-x64-msvc': + specifier: workspace:* + version: link:../splat-transform-native/splat-transform-win32-x64-msvc + + packages/splat-transform-native/source: + dependencies: + cmake-js: + specifier: ^8.0.0 + version: 8.0.0 + node-addon-api: + specifier: ^8.9.0 + version: 8.9.0 + yargs: + specifier: ^18.0.0 + version: 18.0.0 + + packages/splat-transform-native/splat-transform-darwin-arm64: {} + + packages/splat-transform-native/splat-transform-linux-arm64-gnu: {} + + packages/splat-transform-native/splat-transform-linux-x64-gnu: {} + + packages/splat-transform-native/splat-transform-win32-arm64-msvc: {} + + packages/splat-transform-native/splat-transform-win32-x64-msvc: {} + + website: + dependencies: + '@astrojs/markdown-satteri': + specifier: ^0.3.3 + version: 0.3.3 + '@manycore/aholo-viewer': + specifier: workspace:* + version: link:../packages/renderer + '@tweakpane/plugin-essentials': + specifier: ^0.2.1 + version: 0.2.1(tweakpane@4.0.5) + astro: + specifier: ^7.0.6 + version: 7.0.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@22.20.0)(rollup@4.60.3)(yaml@2.9.0) + lz-string: + specifier: ^1.5.0 + version: 1.5.0 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + tweakpane: + specifier: ^4.0.5 + version: 4.0.5 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@astrojs/check': + specifier: ^0.9.9 + version: 0.9.9(prettier-plugin-astro@0.14.1)(prettier@3.9.4)(typescript@6.0.3) + '@tweakpane/core': + specifier: ^2.0.5 + version: 2.0.5 + '@types/node': + specifier: ^22.20.0 + version: 22.20.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite-plugin-checker: + specifier: ^0.14.4 + version: 0.14.4(oxlint@1.73.0)(typescript@6.0.3)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(yaml@2.9.0)) + +packages: + + '@astrojs/check@0.9.9': + resolution: {integrity: sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==} + hasBin: true + peerDependencies: + typescript: ^6.0.3 + + '@astrojs/compiler-binding-darwin-arm64@0.3.0': + resolution: {integrity: sha512-3n0uu+uJpnCq8b4JFi3uGDsIisAvHctxSmH+cIO9Gbei1H1Y1QXaYboXyiWJugUmprr3OEYP7+LdodzpVFzLMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-x64@0.3.0': + resolution: {integrity: sha512-scxNGKjOBydMo1QR4LtK0FMgh7ubQomJDv953nz2msQFkPKke/0FpPv/cQM0T/kuZdReZQFU8Oz3iOrP/6WHEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@astrojs/compiler-binding-linux-arm64-gnu@0.3.0': + resolution: {integrity: sha512-NZrWLolVUANmrnl0zrFK/Sx5Sock1gEUT49ALfMTTCA5Ya2ec/BoJXMIg4KgE+wZcrdXJ8e+WyEhM7YLk/FJkA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-arm64-musl@0.3.0': + resolution: {integrity: sha512-PjwRmKgMFDsFhg82g0poXlIY8Qn3fMA3hXjaR0coJWJzTJsRH9ATU0j2ocigjtU1h3vL/yR7yLUxGj/lTCq73g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-linux-x64-gnu@0.3.0': + resolution: {integrity: sha512-Dr69VJYlnSfyL8gzELW6S4mE41P7TDPn1IKjwMnjdZ7+dxgJI50oMLFSk1LVe26bHmWB3ktuh8fDVK1THI9e9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-x64-musl@0.3.0': + resolution: {integrity: sha512-AEt+bRw8PfImCcyRH1lpXVB8CdmQ1K/wPo5u99iec4/U/XdNvQZ715YVuNzIJpbJXelgQeZ5H2+Ea7XwRyWY5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-wasm32-wasi@0.3.0': + resolution: {integrity: sha512-U80tA1j8V6LjhiTZzVCtG4E8hrNVVNXDGV5fCgJ94q8FU9CPH+XwdDDhLzBybfWhKfyItXmQiZNRPTiPCYTpVg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@astrojs/compiler-binding-win32-arm64-msvc@0.3.0': + resolution: {integrity: sha512-CpY1RII2r1XMpOUVD1VR/F2wtuRsiOCkFULS10Khyj8/DFZMtxVuUCAWGw+CW2Ka0h6eP3Xc1CA+glFlvXMPxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@astrojs/compiler-binding-win32-x64-msvc@0.3.0': + resolution: {integrity: sha512-qmFbs769oeeGrRebAnCW7aBk8m71vf85W/dX/jddfx5Z06/w0wf7TZCfJPOX1Fld2t+4N+iXzfGEJG+zJQ+bzg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@astrojs/compiler-binding@0.3.0': + resolution: {integrity: sha512-zlsOT5COD9hRwplJCgQhS21unxON5AKirf0vgt1ijXwuseYIaZdm2ZOpF8fsz+DY9EyXx+I/ukxtg7uoBep68A==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/compiler-rs@0.3.0': + resolution: {integrity: sha512-J2qEVHtIDjEM9TxwmwuebOGmZNwhKu/dR7P7qBpnJKGmBBX0vdweQ/4cEXhj8fBbWVUB5V12xWChri3CgKNULQ==} + engines: {node: '>=22.12.0'} + + '@astrojs/compiler@2.13.1': + resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} + + '@astrojs/internal-helpers@0.10.1': + resolution: {integrity: sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q==} + + '@astrojs/language-server@2.16.11': + resolution: {integrity: sha512-sJ/EfnFp0+gurTrkvONtd9qRqmMZLT9bHelfI1SA35CaQVTrRrA74qteOcNT/al1b9Atg3IiH1Jk/qfckyC+fg==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-satteri@0.3.3': + resolution: {integrity: sha512-Lje33Ittd8UQGgbIIWQvhPkj5X5c4b1sZnZWX3JQV/AWpfbuQGxVi2ONt6+ScydcwfR4egilslEWyczMclrJ1g==} + + '@astrojs/prism@4.0.2': + resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} + engines: {node: '>=22.12.0'} + + '@astrojs/telemetry@3.3.2': + resolution: {integrity: sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/yaml2ts@0.2.4': + resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bruits/satteri-darwin-arm64@0.9.4': + resolution: {integrity: sha512-W3MSUkr2mZRR8Stoe+lqNAyzQzRuFMU8WffV9IvFSxTok0LGWR0ZZQPLELU4QTRiUbhL2Y4VUP9vV7pj8rHjgg==} + cpu: [arm64] + os: [darwin] + + '@bruits/satteri-darwin-x64@0.9.4': + resolution: {integrity: sha512-DXOuuaE1lsv7mpk2mOvGrzqoEWEvOIZEO/fXVa7zfM23Iob+CBjBkRAMwpHA4pmZ3j6Gj7WJzPKw0kQ7w741AQ==} + cpu: [x64] + os: [darwin] + + '@bruits/satteri-linux-arm64-gnu@0.9.4': + resolution: {integrity: sha512-gJxU9rGGoqIznSEgEzpjxkry24jeHuMpoo1tCIAhHYh7WaD3j5F8zt3jmHxEaN1Uwa+K5+wFgIR2uIGOnMzEmw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-arm64-musl@0.9.4': + resolution: {integrity: sha512-Wjzu9hmmAbfmDkBfPI1VdZygJtYz9uYZQnkEyrXi6S2JFi+2pXQ1A5irj38bqm0IZmWcTbk0cVG4NZnPdtVNJA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@bruits/satteri-linux-x64-gnu@0.9.4': + resolution: {integrity: sha512-MR1Q+wMx65FQlbSV7cRqWW87Knp0zkoaIV55Dt+xZl028wJABXEPEEmG3670SLq7lVZvcGIDwCgSg2kCYxvRwA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-x64-musl@0.9.4': + resolution: {integrity: sha512-T4gxhXve3zyNAZesrXAd/rDZOGRkbfFIUFld4TGsw6BsjoIteCcDji6IMqeXyaWEVSykY2X8Eid2hr6aXGYAaw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@bruits/satteri-wasm32-wasi@0.9.4': + resolution: {integrity: sha512-/CEG8LUlpaBEnhFnYVn0UnlHFLs51UhrkJBUPDUXLzkadzAcnR88iRA/nOl7Zwhjb4WhfBV4p3P5qeOJMtH0iA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@bruits/satteri-win32-arm64-msvc@0.9.4': + resolution: {integrity: sha512-E1ZPQbgCtFKiU7pFYVndynvY7ne4coeVDUgnVThErSFlJ2ceQCBZrfRTD1lzrIDy63Bbqo+g/cZY9duw+JYjIw==} + cpu: [arm64] + os: [win32] + + '@bruits/satteri-win32-x64-msvc@0.9.4': + resolution: {integrity: sha512-5I7SiarsNdAUuhJb50CXJPTwr/ECVrBoU+fymoLjChK5fW//+srhY4lstcNTzgFRtQSYfVtm4OQZz16CVMeTeA==} + cpu: [x64] + os: [win32] + + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@gerrit0/mini-shiki@3.23.0': + resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@microsoft/api-extractor-model@7.33.8': + resolution: {integrity: sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==} + + '@microsoft/api-extractor@7.58.9': + resolution: {integrity: sha512-S2UF4yza5GoxCmf7hJQNxJNZN9ltOVuOQv8Dy+Z21aol5ERoBNMdWcQHm4MJMPPItW4H/4rZD906iaf4mUojJA==} + hasBin: true + + '@microsoft/tsdoc-config@0.18.1': + resolution: {integrity: sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oxc-project/types@0.138.0': + resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + + '@oxfmt/binding-android-arm-eabi@0.58.0': + resolution: {integrity: sha512-Uz62sHduGGPftXtILGyxdSW4PX82rUg+rfdNqhsgxe881g4rIoXlIqmZQ6HVKcF4f+F8qMhdD03Bx5u7gmeTdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.58.0': + resolution: {integrity: sha512-rD0lRaJp1b+9vw6X4A2dJWKukd6X8yxiicN4JxXcXayolmUypRZxk+lKR+fVOu5q/iYc0fh5fR4bgmfOfVlbaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.58.0': + resolution: {integrity: sha512-uzbPPk7O6M+w2K65vcQ1woga3wgP8zghjL1KOG5b6qJ8dvYHZJ1VShaslg2KOK6yQIwCQtcMCXqLBM6sqXUNTg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.58.0': + resolution: {integrity: sha512-L0nKYDxU32oxeQqJj21W9SlIMnf81VZEhyah6iDvFhf5q0oynq498Fopth7blErUJVBpVtxQ98RMCfMPqpJX6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.58.0': + resolution: {integrity: sha512-woNwfD58dC5PGS9LSLSD5JYfo/EFK5iG9vhDWkcCg3q78ag7KC8bpDqgvPHrMoXpx83OLXxoSOhu6z8FsVTHlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.58.0': + resolution: {integrity: sha512-Sqs8nMLxuQpY21NKJ1u4stPDmO5hskBCNNh2E3AdCfI1QqWtf4m+Qn4mGEIUO4KGmuq3SWc/SZ80uy5IiwTCDw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.58.0': + resolution: {integrity: sha512-Vd4exzBI5B5hB9m22JiTQzIL23WvHo/Pe+sNXPNeBLXSP9swCBPKCEBRwKpmpQzYhlgYaCgfPcGXPKAJBRIiZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.58.0': + resolution: {integrity: sha512-bUWi5mHV+4Vi56RLHE1h6q/HHfwAIT3XoB9vJAVeRzfu5NriXM8y6eeJu0vlKa0C9kq2rq1sOWRClhdLHPocrg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.58.0': + resolution: {integrity: sha512-2ZHxemzgHcjtktAuVUwSoyXmGo/t+aF5tS1ciPpPei4rhSyrz3JOqDosXXrmhN/yLUSzJjtuW7ToTWqfQpCj2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.58.0': + resolution: {integrity: sha512-AwKkVwjVmFQ3bcO7j0McGYAqCKH2a326fswfofng/E8VewCT/raeeGQr4huVhY704deK8AWASSTlxzMj0eZc6Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.58.0': + resolution: {integrity: sha512-xsRpTxfUnJF8D3AUKko/qyWdjw4GZVHlCVFuGlzSCTeewLmykKINW8em1+wx+axsDVtJJcMtvsiaXggXxrlHgw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.58.0': + resolution: {integrity: sha512-Z4AYOTcy7nYEIiXwD62PlerimyYRcfJOgUbQAEBjXz098kxKuERBlRntofGy69HHhe9E0TLVNMl1yspVNu+efw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.58.0': + resolution: {integrity: sha512-A3nhhtZPC/TKVWOPj9q/H3p2znJDCcHWYlJBhWL8hGq/bFmBaNBHC8Np6E581yVq1w9Mi3rMDNzDalWvtUfJtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.58.0': + resolution: {integrity: sha512-2g+tVkgwqphw8R4hgo+kF4oz8+P5RwVOtr9+irsC7uwEp0e9j7Crw8kDGKL20uYlLPD7g02DqA61mC/UNYx98A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.58.0': + resolution: {integrity: sha512-rc15P6AbyyB7426aN8AakLd02Trb3a6ML/mmfAQeVHJEfVofWLcWIrBdy6zDEY+DIaL/s8E4GGPboVw+oP3+EA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.58.0': + resolution: {integrity: sha512-ZWoTM27/HYPOh9iq86DAbhPu9nXb8qKvvGU/h8OfliyVUFAMMNTLDkGsWDKKnDqIkqvZ9+dXlgUOsH1LYO3O7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.58.0': + resolution: {integrity: sha512-LHZnqFXe2dEfkRI4XdZS/57nEOT/I4UCRX5IyM9v4GYW9XwQCjGe1IUK59SuKw3POwvcgWQ4pme2cYXmNqTNPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.58.0': + resolution: {integrity: sha512-mZKpg20TpheCJym1rarcZCUJeW1sSruw8zAAaCYWvuVfwIUDN1CXdrPU/JgCWReXTCTrEfCB8Wyo3hh9jSZ2EA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.58.0': + resolution: {integrity: sha512-N/wUU4N5PZ2orBtI+Ko7MnMfYLfE7K91UrGMY/c/pYyHR3lA9kwst1XugkZx+92YcRh/Eo+iv2eTESSWXfiZPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.73.0': + resolution: {integrity: sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.73.0': + resolution: {integrity: sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.73.0': + resolution: {integrity: sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.73.0': + resolution: {integrity: sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.73.0': + resolution: {integrity: sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.73.0': + resolution: {integrity: sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.73.0': + resolution: {integrity: sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.73.0': + resolution: {integrity: sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.73.0': + resolution: {integrity: sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.73.0': + resolution: {integrity: sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.73.0': + resolution: {integrity: sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.73.0': + resolution: {integrity: sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.73.0': + resolution: {integrity: sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.73.0': + resolution: {integrity: sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.73.0': + resolution: {integrity: sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.73.0': + resolution: {integrity: sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.73.0': + resolution: {integrity: sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.73.0': + resolution: {integrity: sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.73.0': + resolution: {integrity: sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm64@1.1.4': + resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.4': + resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.4': + resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.4': + resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.4': + resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.4': + resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.4': + resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.4': + resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.4': + resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.4': + resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.4': + resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.4': + resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.4': + resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@rushstack/node-core-library@5.23.1': + resolution: {integrity: sha512-wlKmIKIYCKuCASbITvOxLZXepPbwXvrv7S6ig6XNWFchSyhL/E2txmVXspHY49Wu2dzf7nI27a2k/yV5BA3EiA==} + peerDependencies: + '@types/node': ^22.20.0 + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/problem-matcher@0.2.1': + resolution: {integrity: sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==} + peerDependencies: + '@types/node': ^22.20.0 + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/rig-package@0.7.3': + resolution: {integrity: sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==} + + '@rushstack/terminal@0.24.0': + resolution: {integrity: sha512-8ZQS4MMaGsv27EXCBiH7WMPkRZrffeDoIevs6z9TM5dzqiY6+Hn4evfK/G+gvgBTjfvfkHIZPQQmalmI2sM4TQ==} + peerDependencies: + '@types/node': ^22.20.0 + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/ts-command-line@5.3.10': + resolution: {integrity: sha512-fwI076HYknC0IrMXdY6UmjDv+PH7NHhNJX3/pY2UblSE5XrXgndXZPiOe/6ZtuFpn6DvVDVNhtkIzQ+Qu/MhVQ==} + + '@shikijs/core@4.3.1': + resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.3.1': + resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/engine-oniguruma@4.3.1': + resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==} + engines: {node: '>=20'} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/langs@4.3.1': + resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.3.1': + resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==} + engines: {node: '>=20'} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/themes@4.3.1': + resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==} + engines: {node: '>=20'} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/types@4.3.1': + resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@tweakpane/core@2.0.5': + resolution: {integrity: sha512-punBgD5rKCF5vcNo6BsSOXiDR/NSs9VM7SG65QSLJIxfRaGgj54ree9zQW6bO3pNFf3AogiGgaNODUVQRk9YqQ==} + + '@tweakpane/plugin-essentials@0.2.1': + resolution: {integrity: sha512-VbFU1/uD+CJNFQdfLXUOLjeG5HyUZH97Ox9CxmyVetg1hqjVun3C83HAGFULyhKzl8tSgii8jr304r8QpdHwzQ==} + peerDependencies: + tweakpane: ^4.0.0 + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/argparse@1.0.38': + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + + '@ungap/structured-clone@1.3.2': + resolution: {integrity: sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==} + + '@volar/kit@2.4.28': + resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} + peerDependencies: + typescript: ^6.0.3 + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/language-server@2.4.28': + resolution: {integrity: sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==} + + '@volar/language-service@2.4.28': + resolution: {integrity: sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + + '@webgpu/types@0.1.71': + resolution: {integrity: sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + am-i-vibing@0.4.0: + resolution: {integrity: sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==} + hasBin: true + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + astro@7.0.6: + resolution: {integrity: sha512-Myw0sFia+zs/Y0yqfZEsUYXfDPh3ELcLf1f0Q/qQzVXBh/af1qO62WNT+P89DCcfGVV51nMoQhEfkBYqJmoUOQ==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + peerDependencies: + '@astrojs/markdown-remark': 7.2.1 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async-generator-function@1.0.0: + resolution: {integrity: sha512-+NAXNqgCrB95ya4Sr66i1CL2hqLVckAk7xwRYWdcm39/ELQ6YNn1aw5r0bdQtqNZgQpEWzc5yc/igXc7aL5SLA==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmake-js@8.0.0: + resolution: {integrity: sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.3.6: + resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.1: + resolution: {integrity: sha512-fk1ZVEeOX9hVZ6QzoBNEC55+Ucqg4sTVwrVuigZhuRPESVFpMyXnd3sbXvPOwp7Y9riVyANiqhEuRF0G1aVSeQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@5.0.0-beta.4: + resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + + lint-staged@17.0.8: + resolution: {integrity: sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==} + engines: {node: '>=22.22.1'} + hasBin: true + + listr2@10.2.2: + resolution: {integrity: sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==} + engines: {node: '>=22.13.0'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + hasBin: true + + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.3: + resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} + engines: {node: 18 || 20 || >=22} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-addon-api@8.9.0: + resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==} + engines: {node: ^18 || ^20 || >= 21} + + node-api-headers@1.9.0: + resolution: {integrity: sha512-2oNILP4jXwRB4ywnYKjVk1YyJ96n2D4EOVJO6S3oYZ5PtbJrw3Yt9TpAuX3nBLMuzn74rnfGQrv13pS9vC+YiA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + oxfmt@0.58.0: + resolution: {integrity: sha512-8feG/7NVEHDVwc1OUpP6Pks+TnaDFUw2jLLFIMi5bcmmwxAX2wBQvjSzj62RRTYBf2Op1Wt8xbkmagmPTR5ETg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.73.0: + resolution: {integrity: sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.24.0' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + p-limit@7.3.0: + resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + engines: {node: '>=20'} + + p-queue@9.3.1: + resolution: {integrity: sha512-POWdiIPmsUPGwb4FeQ4OBg46aqmcInSWe45CKDsGHiOBiVQM9chqfQTuqhuTzcg2Vz9faTI65at0KkVyVEiCHw==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + package-manager-detector@1.7.0: + resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prettier-plugin-astro@0.14.1: + resolution: {integrity: sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==} + engines: {node: ^14.15.0 || >=16.0.0} + + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + engines: {node: '>=14'} + hasBin: true + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + process-ancestry@0.1.0: + resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} + engines: {node: '>=18.0.0'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.1.4: + resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + s.color@0.0.15: + resolution: {integrity: sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass-formatter@0.7.9: + resolution: {integrity: sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==} + + satteri@0.9.4: + resolution: {integrity: sha512-BKob126Tay84diOZsnVNH/Q/c+3njPJTCad3w5zLKa6j8bVjxskPNHDtxrMwYK4bN/RlqUSdMnPwKY4k65EMOQ==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shiki@4.3.1: + resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==} + engines: {node: '>=20'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + + smol-toml@1.7.0: + resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + suf-log@2.5.3: + resolution: {integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svgo@4.0.1: + resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} + engines: {node: '>=16'} + hasBin: true + + tar@7.5.19: + resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==} + engines: {node: '>=18'} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyclip@0.1.15: + resolution: {integrity: sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tweakpane@4.0.5: + resolution: {integrity: sha512-rxEXdSI+ArlG1RyO6FghC4ZUX8JkEfz8F3v1JuteXSV0pEtHJzyo07fcDG+NsJfN5L39kSbCYbB9cBGHyuI/tQ==} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typedoc@0.28.20: + resolution: {integrity: sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: ^6.0.3 + + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.6.0: + resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.4: + resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-plugin-checker@0.14.4: + resolution: {integrity: sha512-Tw0U9UgHIRiZ+Yoe4Gh0RrYoBiCVmO9j4tomVdYr0KUjUsqXMPhqW8ouoSWmOzGp5Iimipbl3bNXZcK7OeP7Qg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@biomejs/biome': '>=2.4.12' + eslint: '>=9.39.4' + meow: ^13.2.0 || ^14.0.0 + optionator: ^0.9.4 + oxlint: '>=1' + stylelint: '>=16.26.1' + typescript: ^6.0.3 + vite: '>=5.4.21' + vue-tsc: ~2.2.10 || ^3.0.0 + peerDependenciesMeta: + '@biomejs/biome': + optional: true + eslint: + optional: true + meow: + optional: true + optionator: + optional: true + oxlint: + optional: true + stylelint: + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + vite@8.1.3: + resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^22.20.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.28.1 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + volar-service-css@0.0.71: + resolution: {integrity: sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.71: + resolution: {integrity: sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.71: + resolution: {integrity: sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.71: + resolution: {integrity: sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.71: + resolution: {integrity: sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-typescript@0.0.71: + resolution: {integrity: sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-yaml@0.0.71: + resolution: {integrity: sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.10: + resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} + + vscode-html-languageservice@5.6.2: + resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-jsonrpc@9.0.1: + resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-protocol@3.18.2: + resolution: {integrity: sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + which-pm-runs@1.1.0: + resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} + engines: {node: '>=4'} + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml-language-server@1.23.0: + resolution: {integrity: sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==} + hasBin: true + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/check@0.9.9(prettier-plugin-astro@0.14.1)(prettier@3.9.4)(typescript@6.0.3)': + dependencies: + '@astrojs/language-server': 2.16.11(prettier-plugin-astro@0.14.1)(prettier@3.9.4)(typescript@6.0.3) + chokidar: 4.0.3 + kleur: 4.1.5 + typescript: 6.0.3 + yargs: 17.7.3 + transitivePeerDependencies: + - prettier + - prettier-plugin-astro + + '@astrojs/compiler-binding-darwin-arm64@0.3.0': + optional: true + + '@astrojs/compiler-binding-darwin-x64@0.3.0': + optional: true + + '@astrojs/compiler-binding-linux-arm64-gnu@0.3.0': + optional: true + + '@astrojs/compiler-binding-linux-arm64-musl@0.3.0': + optional: true + + '@astrojs/compiler-binding-linux-x64-gnu@0.3.0': + optional: true + + '@astrojs/compiler-binding-linux-x64-musl@0.3.0': + optional: true + + '@astrojs/compiler-binding-wasm32-wasi@0.3.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@astrojs/compiler-binding-win32-arm64-msvc@0.3.0': + optional: true + + '@astrojs/compiler-binding-win32-x64-msvc@0.3.0': + optional: true + + '@astrojs/compiler-binding@0.3.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)': + optionalDependencies: + '@astrojs/compiler-binding-darwin-arm64': 0.3.0 + '@astrojs/compiler-binding-darwin-x64': 0.3.0 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.3.0 + '@astrojs/compiler-binding-linux-arm64-musl': 0.3.0 + '@astrojs/compiler-binding-linux-x64-gnu': 0.3.0 + '@astrojs/compiler-binding-linux-x64-musl': 0.3.0 + '@astrojs/compiler-binding-wasm32-wasi': 0.3.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) + '@astrojs/compiler-binding-win32-arm64-msvc': 0.3.0 + '@astrojs/compiler-binding-win32-x64-msvc': 0.3.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@astrojs/compiler-rs@0.3.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)': + dependencies: + '@astrojs/compiler-binding': 0.3.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@astrojs/compiler@2.13.1': {} + + '@astrojs/internal-helpers@0.10.1': + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + js-yaml: 4.3.0 + picomatch: 4.0.5 + retext-smartypants: 6.2.0 + shiki: 4.3.1 + smol-toml: 1.7.0 + unified: 11.0.5 + + '@astrojs/language-server@2.16.11(prettier-plugin-astro@0.14.1)(prettier@3.9.4)(typescript@6.0.3)': + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/yaml2ts': 0.2.4 + '@jridgewell/sourcemap-codec': 1.5.5 + '@volar/kit': 2.4.28(typescript@6.0.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28 + '@volar/language-service': 2.4.28 + muggle-string: 0.4.1 + tinyglobby: 0.2.17 + volar-service-css: 0.0.71(@volar/language-service@2.4.28) + volar-service-emmet: 0.0.71(@volar/language-service@2.4.28) + volar-service-html: 0.0.71(@volar/language-service@2.4.28) + volar-service-prettier: 0.0.71(@volar/language-service@2.4.28)(prettier@3.9.4) + volar-service-typescript: 0.0.71(@volar/language-service@2.4.28) + volar-service-typescript-twoslash-queries: 0.0.71(@volar/language-service@2.4.28) + volar-service-yaml: 0.0.71(@volar/language-service@2.4.28) + vscode-html-languageservice: 5.6.2 + vscode-uri: 3.1.0 + optionalDependencies: + prettier: 3.9.4 + prettier-plugin-astro: 0.14.1 + transitivePeerDependencies: + - typescript + + '@astrojs/markdown-satteri@0.3.3': + dependencies: + '@astrojs/internal-helpers': 0.10.1 + '@astrojs/prism': 4.0.2 + github-slugger: 2.0.0 + satteri: 0.9.4 + + '@astrojs/prism@4.0.2': + dependencies: + prismjs: 1.30.0 + + '@astrojs/telemetry@3.3.2': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + is-wsl: 3.1.1 + which-pm-runs: 1.1.0 + + '@astrojs/yaml2ts@0.2.4': + dependencies: + yaml: 2.9.0 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bruits/satteri-darwin-arm64@0.9.4': + optional: true + + '@bruits/satteri-darwin-x64@0.9.4': + optional: true + + '@bruits/satteri-linux-arm64-gnu@0.9.4': + optional: true + + '@bruits/satteri-linux-arm64-musl@0.9.4': + optional: true + + '@bruits/satteri-linux-x64-gnu@0.9.4': + optional: true + + '@bruits/satteri-linux-x64-musl@0.9.4': + optional: true + + '@bruits/satteri-wasm32-wasi@0.9.4': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.9.4': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.9.4': + optional: true + + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@gerrit0/mini-shiki@3.23.0': + dependencies: + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@microsoft/api-extractor-model@7.33.8(@types/node@22.20.0)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.1 + '@rushstack/node-core-library': 5.23.1(@types/node@22.20.0) + transitivePeerDependencies: + - '@types/node' + + '@microsoft/api-extractor@7.58.9(@types/node@22.20.0)': + dependencies: + '@microsoft/api-extractor-model': 7.33.8(@types/node@22.20.0) + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.1 + '@rushstack/node-core-library': 5.23.1(@types/node@22.20.0) + '@rushstack/rig-package': 0.7.3 + '@rushstack/terminal': 0.24.0(@types/node@22.20.0) + '@rushstack/ts-command-line': 5.3.10(@types/node@22.20.0) + diff: 8.0.4 + minimatch: 10.2.3 + resolve: 1.22.12 + semver: 7.7.4 + source-map: 0.6.1 + typescript: 6.0.3 + transitivePeerDependencies: + - '@types/node' + + '@microsoft/tsdoc-config@0.18.1': + dependencies: + '@microsoft/tsdoc': 0.16.0 + ajv: 8.18.0 + jju: 1.4.0 + resolve: 1.22.12 + + '@microsoft/tsdoc@0.16.0': {} + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oslojs/encoding@1.1.0': {} + + '@oxc-project/types@0.138.0': {} + + '@oxfmt/binding-android-arm-eabi@0.58.0': + optional: true + + '@oxfmt/binding-android-arm64@0.58.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.58.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.58.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.58.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.58.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.58.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.58.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.58.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.58.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.58.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.58.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.58.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.58.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.58.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.58.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.58.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.58.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.58.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.73.0': + optional: true + + '@oxlint/binding-android-arm64@1.73.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.73.0': + optional: true + + '@oxlint/binding-darwin-x64@1.73.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.73.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.73.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.73.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.73.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.73.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.73.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.73.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.73.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.73.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.73.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.73.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.73.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.73.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.73.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.73.0': + optional: true + + '@rolldown/binding-android-arm64@1.1.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.4': + optional: true + + '@rolldown/binding-darwin-x64@1.1.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.4': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.4': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/pluginutils@5.4.0(rollup@4.60.3)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.5 + optionalDependencies: + rollup: 4.60.3 + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@rushstack/node-core-library@5.23.1(@types/node@22.20.0)': + dependencies: + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) + ajv-formats: 3.0.1(ajv@8.18.0) + fs-extra: 11.3.6 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.12 + semver: 7.7.4 + optionalDependencies: + '@types/node': 22.20.0 + + '@rushstack/problem-matcher@0.2.1(@types/node@22.20.0)': + optionalDependencies: + '@types/node': 22.20.0 + + '@rushstack/rig-package@0.7.3': + dependencies: + jju: 1.4.0 + resolve: 1.22.12 + + '@rushstack/terminal@0.24.0(@types/node@22.20.0)': + dependencies: + '@rushstack/node-core-library': 5.23.1(@types/node@22.20.0) + '@rushstack/problem-matcher': 0.2.1(@types/node@22.20.0) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 22.20.0 + + '@rushstack/ts-command-line@5.3.10(@types/node@22.20.0)': + dependencies: + '@rushstack/terminal': 0.24.0(@types/node@22.20.0) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + + '@shikijs/core@4.3.1': + dependencies: + '@shikijs/primitive': 4.3.1 + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/engine-oniguruma@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/langs@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + + '@shikijs/primitive@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@4.3.1': + dependencies: + '@shikijs/types': 4.3.1 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/types@4.3.1': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@tweakpane/core@2.0.5': {} + + '@tweakpane/plugin-essentials@0.2.1(tweakpane@4.0.5)': + dependencies: + tweakpane: 4.0.5 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/argparse@1.0.38': {} + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.8': + optional: true + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + + '@types/semver@7.7.1': {} + + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@3.0.3': {} + + '@types/webxr@0.5.24': {} + + '@ungap/structured-clone@1.3.2': {} + + '@volar/kit@2.4.28(typescript@6.0.3)': + dependencies: + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + typesafe-path: 0.2.2 + typescript: 6.0.3 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/language-server@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.18.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-service@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + vscode-languageserver-protocol: 3.18.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-uri: 3.1.0 + + '@vscode/l10n@0.0.18': {} + + '@webgpu/types@0.1.71': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + ajv-draft-04@1.0.0(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + am-i-vibing@0.4.0: + dependencies: + process-ancestry: 0.1.0 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + astro@7.0.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@22.20.0)(rollup@4.60.3)(yaml@2.9.0): + dependencies: + '@astrojs/compiler-rs': 0.3.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2) + '@astrojs/internal-helpers': 0.10.1 + '@astrojs/markdown-satteri': 0.3.3 + '@astrojs/telemetry': 3.3.2 + '@capsizecss/unpack': 4.0.1 + '@clack/prompts': 1.7.0 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.4.0(rollup@4.60.3) + am-i-vibing: 0.4.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 1.1.1 + devalue: 5.8.1 + diff: 8.0.4 + dset: 3.1.4 + es-module-lexer: 2.3.0 + esbuild: 0.28.1 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.4 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + js-yaml: 4.3.0 + jsonc-parser: 3.3.1 + magic-string: 0.30.21 + magicast: 0.5.3 + mrmime: 2.0.1 + neotraverse: 0.6.18 + obug: 2.1.3 + p-limit: 7.3.0 + p-queue: 9.3.1 + package-manager-detector: 1.7.0 + piccolore: 0.1.3 + picomatch: 4.0.5 + semver: 7.8.5 + shiki: 4.3.1 + smol-toml: 1.7.0 + svgo: 4.0.1 + tinyclip: 0.1.15 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + ultrahtml: 1.6.0 + unifont: 0.7.4 + unstorage: 1.17.5 + vite: 8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(yaml@2.9.0)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.4.3 + optionalDependencies: + sharp: 0.35.3(@types/node@22.20.0) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@emnapi/core' + - '@emnapi/runtime' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - uploadthing + - yaml + + async-function@1.0.0: {} + + async-generator-function@1.0.0: {} + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + balanced-match@4.0.4: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + boolbase@1.0.0: {} + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.1 + + ccount@2.0.1: {} + + chalk@5.6.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chownr@3.0.0: {} + + ci-info@4.4.0: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.1 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + clsx@2.1.1: {} + + cmake-js@8.0.0: + dependencies: + debug: 4.4.3 + fs-extra: 11.3.6 + node-api-headers: 1.9.0 + rc: 1.2.8 + semver: 7.8.5 + tar: 7.5.19 + url-join: 4.0.1 + which: 6.0.1 + yargs: 17.7.3 + transitivePeerDependencies: + - supports-color + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@2.0.0: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-extend@0.6.0: {} + + defu@6.1.7: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devalue@5.8.1: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@8.0.4: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dset@3.1.4: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + entities@4.5.0: {} + + environment@1.1.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + estree-walker@2.0.2: {} + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.3: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fflate@0.8.3: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.3.6: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generator-function@2.0.1: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.1: + dependencies: + async-function: 1.0.0 + async-generator-function: 1.0.0 + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + generator-function: 2.0.1 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-tsconfig@5.0.0-beta.4: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + http-cache-semantics@4.2.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + husky@9.1.7: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + import-lazy@4.0.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ipaddr.js@1.9.1: {} + + iron-webcrypto@1.2.1: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-docker@3.0.0: {} + + is-docker@4.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@4.0.0: {} + + jju@1.4.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + kleur@4.1.5: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + + lint-staged@17.0.8: + dependencies: + listr2: 10.2.2 + picomatch: 4.0.5 + string-argv: 0.3.2 + tinyexec: 1.2.4 + optionalDependencies: + yaml: 2.9.0 + + listr2@10.2.2: + dependencies: + cli-truncate: 5.2.0 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 10.0.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + lru-cache@11.5.2: {} + + lunr@2.3.9: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + marked@14.0.0: {} + + math-intrinsics@1.1.0: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.2 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + mdurl@2.0.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-function@5.0.1: {} + + minimatch@10.2.3: + dependencies: + brace-expansion: 5.0.7 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.15: {} + + negotiator@1.0.0: {} + + neotraverse@0.6.18: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-addon-api@8.9.0: {} + + node-api-headers@1.9.0: {} + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.4: {} + + normalize-path@3.0.0: {} + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-inspect@1.13.4: {} + + obug@2.1.3: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.11: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + oxfmt@0.58.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.58.0 + '@oxfmt/binding-android-arm64': 0.58.0 + '@oxfmt/binding-darwin-arm64': 0.58.0 + '@oxfmt/binding-darwin-x64': 0.58.0 + '@oxfmt/binding-freebsd-x64': 0.58.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.58.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.58.0 + '@oxfmt/binding-linux-arm64-gnu': 0.58.0 + '@oxfmt/binding-linux-arm64-musl': 0.58.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.58.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.58.0 + '@oxfmt/binding-linux-riscv64-musl': 0.58.0 + '@oxfmt/binding-linux-s390x-gnu': 0.58.0 + '@oxfmt/binding-linux-x64-gnu': 0.58.0 + '@oxfmt/binding-linux-x64-musl': 0.58.0 + '@oxfmt/binding-openharmony-arm64': 0.58.0 + '@oxfmt/binding-win32-arm64-msvc': 0.58.0 + '@oxfmt/binding-win32-ia32-msvc': 0.58.0 + '@oxfmt/binding-win32-x64-msvc': 0.58.0 + + oxlint@1.73.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.73.0 + '@oxlint/binding-android-arm64': 1.73.0 + '@oxlint/binding-darwin-arm64': 1.73.0 + '@oxlint/binding-darwin-x64': 1.73.0 + '@oxlint/binding-freebsd-x64': 1.73.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.73.0 + '@oxlint/binding-linux-arm-musleabihf': 1.73.0 + '@oxlint/binding-linux-arm64-gnu': 1.73.0 + '@oxlint/binding-linux-arm64-musl': 1.73.0 + '@oxlint/binding-linux-ppc64-gnu': 1.73.0 + '@oxlint/binding-linux-riscv64-gnu': 1.73.0 + '@oxlint/binding-linux-riscv64-musl': 1.73.0 + '@oxlint/binding-linux-s390x-gnu': 1.73.0 + '@oxlint/binding-linux-x64-gnu': 1.73.0 + '@oxlint/binding-linux-x64-musl': 1.73.0 + '@oxlint/binding-openharmony-arm64': 1.73.0 + '@oxlint/binding-win32-arm64-msvc': 1.73.0 + '@oxlint/binding-win32-ia32-msvc': 1.73.0 + '@oxlint/binding-win32-x64-msvc': 1.73.0 + + p-limit@7.3.0: + dependencies: + yocto-queue: 1.2.2 + + p-queue@9.3.1: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + + package-manager-detector@1.7.0: {} + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + path-to-regexp@8.4.2: {} + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier-plugin-astro@0.14.1: + dependencies: + '@astrojs/compiler': 2.13.1 + prettier: 3.9.4 + sass-formatter: 0.7.9 + optional: true + + prettier@3.9.4: {} + + prismjs@1.30.0: {} + + process-ancestry@0.1.0: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + property-information@7.2.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode.js@2.3.1: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + radix3@1.1.2: {} + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + request-light@0.5.8: {} + + request-light@0.7.0: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + retry@0.12.0: {} + + rfdc@1.4.1: {} + + rolldown@1.1.4: + dependencies: + '@oxc-project/types': 0.138.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.4 + '@rolldown/binding-darwin-arm64': 1.1.4 + '@rolldown/binding-darwin-x64': 1.1.4 + '@rolldown/binding-freebsd-x64': 1.1.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 + '@rolldown/binding-linux-arm64-gnu': 1.1.4 + '@rolldown/binding-linux-arm64-musl': 1.1.4 + '@rolldown/binding-linux-ppc64-gnu': 1.1.4 + '@rolldown/binding-linux-s390x-gnu': 1.1.4 + '@rolldown/binding-linux-x64-gnu': 1.1.4 + '@rolldown/binding-linux-x64-musl': 1.1.4 + '@rolldown/binding-openharmony-arm64': 1.1.4 + '@rolldown/binding-wasm32-wasi': 1.1.4 + '@rolldown/binding-win32-arm64-msvc': 1.1.4 + '@rolldown/binding-win32-x64-msvc': 1.1.4 + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + optional: true + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + s.color@0.0.15: + optional: true + + safer-buffer@2.1.2: {} + + sass-formatter@0.7.9: + dependencies: + suf-log: 2.5.3 + optional: true + + satteri@0.9.4: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-darwin-arm64': 0.9.4 + '@bruits/satteri-darwin-x64': 0.9.4 + '@bruits/satteri-linux-arm64-gnu': 0.9.4 + '@bruits/satteri-linux-arm64-musl': 0.9.4 + '@bruits/satteri-linux-x64-gnu': 0.9.4 + '@bruits/satteri-linux-x64-musl': 0.9.4 + '@bruits/satteri-wasm32-wasi': 0.9.4 + '@bruits/satteri-win32-arm64-msvc': 0.9.4 + '@bruits/satteri-win32-x64-msvc': 0.9.4 + + sax@1.6.0: {} + + semver@7.7.4: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + sharp@0.35.3(@types/node@22.20.0): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 22.20.0 + optional: true + + shiki@4.3.1: + dependencies: + '@shikijs/core': 4.3.1 + '@shikijs/engine-javascript': 4.3.1 + '@shikijs/engine-oniguruma': 4.3.1 + '@shikijs/langs': 4.3.1 + '@shikijs/themes': 4.3.1 + '@shikijs/types': 4.3.1 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.1 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.1 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + smol-toml@1.7.0: {} + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + space-separated-tokens@2.0.2: {} + + sprintf-js@1.0.3: {} + + statuses@2.0.2: {} + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@2.0.1: {} + + suf-log@2.5.3: + dependencies: + s.color: 0.0.15 + optional: true + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svgo@4.0.1: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.2.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + + tar@7.5.19: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + tiny-inflate@1.0.3: {} + + tiny-invariant@1.3.3: {} + + tinyclip@0.1.15: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@2.1.0: {} + + toidentifier@1.0.1: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: {} + + tweakpane@4.0.5: {} + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typedoc@0.28.20(typescript@6.0.3): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.3.0 + minimatch: 10.2.5 + typescript: 6.0.3 + yaml: 2.9.0 + + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.8.5 + + typescript@6.0.3: {} + + uc.micro@2.1.0: {} + + ufo@1.6.4: {} + + ultrahtml@1.6.0: {} + + uncrypto@0.1.3: {} + + undici-types@6.21.0: {} + + unicorn-magic@0.3.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.4: + dependencies: + css-tree: 3.2.1 + ofetch: 1.5.1 + ohash: 2.0.11 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.2 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + url-join@4.0.1: {} + + vary@1.1.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite-plugin-checker@0.14.4(oxlint@1.73.0)(typescript@6.0.3)(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(yaml@2.9.0)): + dependencies: + '@babel/code-frame': 7.29.7 + chokidar: 5.0.0 + npm-run-path: 6.0.0 + picocolors: 1.1.1 + picomatch: 4.0.5 + proper-lockfile: 4.1.2 + tiny-invariant: 1.3.3 + vite: 8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(yaml@2.9.0) + optionalDependencies: + oxlint: 1.73.0 + typescript: 6.0.3 + + vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.16 + rolldown: 1.1.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.0 + esbuild: 0.28.1 + fsevents: 2.3.3 + yaml: 2.9.0 + + vitefu@1.1.3(vite@8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(yaml@2.9.0)): + optionalDependencies: + vite: 8.1.3(@types/node@22.20.0)(esbuild@0.28.1)(yaml@2.9.0) + + volar-service-css@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-css-languageservice: 6.3.10 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-emmet@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-html@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-html-languageservice: 5.6.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-prettier@0.0.71(@volar/language-service@2.4.28)(prettier@3.9.4): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + prettier: 3.9.4 + + volar-service-typescript-twoslash-queries@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-typescript@0.0.71(@volar/language-service@2.4.28): + dependencies: + path-browserify: 1.0.1 + semver: 7.8.5 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.12 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-yaml@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + yaml-language-server: 1.23.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + vscode-css-languageservice@6.3.10: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-html-languageservice@5.6.2: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-uri: 3.1.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + + vscode-jsonrpc@8.2.0: {} + + vscode-jsonrpc@9.0.1: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-protocol@3.18.2: + dependencies: + vscode-jsonrpc: 9.0.1 + vscode-languageserver-types: 3.18.0 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver-types@3.18.0: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@3.1.0: {} + + which-pm-runs@1.1.0: {} + + which@6.0.1: + dependencies: + isexe: 4.0.0 + + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.1 + strip-ansi: 7.2.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + xxhash-wasm@1.1.0: {} + + y18n@5.0.8: {} + + yallist@5.0.0: {} + + yaml-language-server@1.23.0: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + prettier: 3.9.4 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-uri: 3.1.0 + yaml: 2.8.3 + + yaml@2.8.3: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs-parser@22.0.0: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yocto-queue@1.2.2: {} + + zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..369b835 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,38 @@ +packages: + - 'website' + - 'packages/*' + - 'external/egs-core/packages/*' + - 'external/egs-core/packages/loaders/*' + - 'external/egs-core/packages/utils/*' + - 'external/egs-core/tools/utils' + - 'packages/splat-transform-native/*' + - 'external/splat-dev-server' + +minimumReleaseAge: 0 + +allowBuilds: + esbuild: false + sharp: false + webgpu: false + +overrides: + '@internal/utils': 'workspace:*' + '@qunhe/egs-typings': 'workspace:*' + '@qunhe/egs-lib': 'workspace:*' + '@qunhe/egs': 'workspace:*' + '@qunhe/egs-animation': 'workspace:*' + '@qunhe/egs-texture-loader': 'workspace:*' + '@qunhe/egs-gltf-loader': 'workspace:*' + '@qunhe/egs-draco-loader': 'workspace:*' + '@qunhe/egs-splat-loader': 'workspace:*' + '@qunhe/egs-splat-utils': 'workspace:*' + '@manycore/aholo-splat-transform': 'workspace:*' + '@manycore/aholo-splat-dev-server': 'workspace:*' + '@manycore/aholo-splat-transform-linux-arm64-gnu': 'workspace:*' + '@manycore/aholo-splat-transform-linux-x64-gnu': 'workspace:*' + '@manycore/aholo-splat-transform-win32-arm64-msvc': 'workspace:*' + '@manycore/aholo-splat-transform-win32-x64-msvc': 'workspace:*' + '@manycore/aholo-splat-transform-darwin-arm64': 'workspace:*' + '@types/node': '^22.20.0' + 'typescript': '^6.0.3' + 'esbuild': '^0.28.1' diff --git a/scripts/build-package.mjs b/scripts/build-package.mjs new file mode 100644 index 0000000..77725ad --- /dev/null +++ b/scripts/build-package.mjs @@ -0,0 +1,349 @@ +import { existsSync, statSync } from 'node:fs'; +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { basename, dirname, extname, isAbsolute, relative, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { rollup } from '@internal/utils/dts-rollup.js'; +import { + readJsonFile, + readPackageJson, + workspaceRoot, + listFiles, + resolvePackagePath, + resolvePackageRoot, + resolveWorkspacePath, + runCommandOrExit, + toPosixPath, +} from './package-utils.mjs'; + +const packageRoot = resolvePackageRoot(); +const packageJson = readPackageJson(packageRoot); +const packageName = packageJson.name ?? packageRoot; +const packageRequire = createRequire(resolve(packageRoot, 'package.json')); +const { build } = await importPackageDependency('esbuild'); + +const srcEntries = [resolvePackagePath(packageRoot, 'src/index.ts', 'source entry')]; +const runtimeDir = resolvePackagePath(packageRoot, 'dist', 'runtime output'); +const declarationOutput = resolvePackagePath(packageRoot, 'dist/index.d.ts', 'declaration output'); +const declarationDir = dirname(declarationOutput); +const tempTypesDir = resolve(packageRoot, 'build'); +const egsPackagesRoot = resolveWorkspacePath('external/egs-core/packages', 'EGS packages root'); +const workerBundles = [ + createWorkerBundle({ + entry: 'external/egs-core/packages/loaders/splat-loader/worker.ts', + entryLabel: 'splat worker entry', + fileName: 'splat-worker.js', + factorySourceFilter: /loaders[\\/]splat-loader[\\/]index\.ts$/, + factoryName: 'WorkerFactor', + blobUrlName: 'SplatWorkerBlobUrl', + nextStatement: 'const poll =', + }), + createWorkerBundle({ + entry: 'external/egs-core/packages/loaders/texture-loader/ktx2/basis/worker/transcoder.worker.ts', + entryLabel: 'texture transcoder worker entry', + fileName: 'transcoder-worker.js', + factorySourceFilter: /loaders[\\/]texture-loader[\\/]ktx2[\\/]basis[\\/]worker[\\/]pool\.ts$/, + factoryName: 'WorkerFactor', + blobUrlName: 'TranscoderWorkerBlobUrl', + nextStatement: 'const pool =', + }), + createWorkerBundle({ + entry: 'external/egs-core/packages/utils/splat-utils/worker.ts', + entryLabel: 'splat sort worker entry', + fileName: 'splat-sort-worker.js', + factorySourceFilter: /utils[\\/]splat-utils[\\/]sort\.ts$/, + factoryName: 'WorkerFactor', + blobUrlName: 'SplatSortWorkerBlobUrl', + nextStatement: 'const poll =', + }), +]; + +const declareOnlyClasses = [ + { name: 'Viewer', exported: 'export declare class Viewer', unexported: 'declare class Viewer' }, + { name: 'Viewport', exported: 'export declare class Viewport', unexported: 'declare class Viewport' }, + { + name: 'Material', + exported: 'export declare abstract class Material', + unexported: 'declare abstract class Material', + }, + { name: 'Light', exported: 'export declare abstract class Light', unexported: 'declare abstract class Light' }, +]; + +await mkdir(runtimeDir, { recursive: true }); +await mkdir(declarationDir, { recursive: true }); +await rm(tempTypesDir, { recursive: true, force: true }); +await mkdir(tempTypesDir, { recursive: true }); + +await bundleRuntime(); +emitPackageDeclarations(); +await bundleDeclarations(); +await stripPrivateTypingsImports(); +await rm(tempTypesDir, { recursive: true, force: true }); + +console.log(`[package-build] Built ${packageName}.`); + +async function importPackageDependency(name) { + try { + const resolvedPath = packageRequire.resolve(name); + + return await import(pathToFileURL(resolvedPath).href); + } catch (error) { + throw new Error(`Unable to load ${name} from ${packageName}. Is it listed in that package's dependencies?`, { + cause: error, + }); + } +} + +async function bundleRuntime() { + const shared = { + absWorkingDir: packageRoot, + bundle: true, + format: 'esm', + logLevel: 'info', + minify: false, + platform: 'browser', + sourcemap: true, + target: 'es2020', + treeShaking: true, + loader: { + '.jpg': 'dataurl', + '.png': 'dataurl', + }, + plugins: [workerBundlePatchPlugin(workerBundles), textureLoaderPatchPlugin(), dracoLoaderPatchPlugin()], + }; + + await build({ + ...shared, + entryPoints: srcEntries, + outdir: runtimeDir, + }); + + for (const workerBundle of workerBundles) { + await build({ + ...shared, + entryPoints: [workerBundle.entry], + outfile: resolve(runtimeDir, workerBundle.fileName), + }); + } +} + +function emitPackageDeclarations() { + runCommandOrExit( + process.execPath, + [ + packageRequire.resolve('typescript/bin/tsc'), + '--emitDeclarationOnly', + '--declarationMap', + 'false', + '--outDir', + toPosixPath(tempTypesDir), + ], + { + cwd: packageRoot, + label: 'tsc --emitDeclarationOnly', + }, + ); +} + +async function bundleDeclarations() { + rollup(packageRoot, { + typeOnlyExports: [ + { name: 'Viewer', type: 'class', abstract: false }, + { name: 'Viewport', type: 'class', abstract: false }, + { name: 'Material', type: 'class', abstract: true }, + { name: 'Light', type: 'class', abstract: true }, + ], + extractorConfig: { + bundledPackages: [ + '@qunhe/egs', + '@qunhe/egs-animation', + '@qunhe/egs-texture-loader', + '@qunhe/egs-gltf-loader', + '@qunhe/egs-draco-loader', + '@qunhe/egs-splat-loader', + '@qunhe/egs-splat-utils', + '@qunhe/egs-lib', + ], + }, + }); + await cp(resolve(packageRoot, 'build/index.d.ts'), declarationOutput, { force: true }); +} + +async function stripPrivateTypingsImports() { + const source = await readFile(declarationOutput, 'utf8'); + const rewritten = source.replace(/^\s*import\s+type\s+\{\s*\}\s+from\s+["']@qunhe\/egs-typings["'];?\r?\n?/gm, ''); + + if (rewritten !== source) { + await writeFile(declarationOutput, rewritten); + } +} + +function getPackageJsonPaths(dir) { + return listFiles(dir, { skipDirectories: ['build', 'dist', 'node_modules'] }).filter( + filePath => basename(filePath) === 'package.json', + ); +} + +function isInsidePackage(filePath) { + const relativePath = relative(packageRoot, filePath); + + return relativePath === '' || (!!relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)); +} + +function getInheritedConfigPaths(configPaths, fallbackPaths) { + const paths = Array.isArray(configPaths) && configPaths.length > 0 ? configPaths : fallbackPaths; + + return paths.map(configPath => toRelativeConfigPath(relative(tempTypesDir, resolve(packageRoot, configPath)))); +} + +function toRelativeConfigPath(filePath) { + const posixPath = toPosixPath(filePath) || '.'; + + return posixPath.startsWith('.') ? posixPath : `./${posixPath}`; +} + +function replaceExtension(filePath, replacementExtension) { + const currentExtension = extname(filePath); + + return currentExtension + ? `${filePath.slice(0, -currentExtension.length)}${replacementExtension}` + : `${filePath}${replacementExtension}`; +} + +function sanitizePathSegment(value) { + return value + .replace(/^@/, '') + .replace(/[\\/]/g, '-') + .replace(/[^a-zA-Z0-9._-]/g, '-'); +} + +function splitNamedSpecifiers(specifierText) { + return specifierText + .split(',') + .map(specifier => specifier.trim()) + .filter(Boolean); +} + +function getNamedSpecifierName(specifier) { + return removeTypeModifier(specifier) + .split(/\s+as\s+/u)[0] + .trim(); +} + +function removeTypeModifier(specifier) { + return specifier.replace(/^type\s+/u, '').trim(); +} + +function createWorkerBundle(config) { + return { + ...config, + entry: resolveWorkspacePath(config.entry, config.entryLabel), + }; +} + +function workerBundlePatchPlugin(workerBundles) { + return { + name: 'aholo-worker-bundle-patch', + setup(buildContext) { + for (const workerBundle of workerBundles) { + buildContext.onLoad({ filter: workerBundle.factorySourceFilter }, async args => { + const source = await readFile(args.path, 'utf8'); + const contents = replaceWorkerFactory(source, workerBundle); + + if (contents === source) { + throw new Error(`Unable to patch ${workerBundle.entryLabel}.`); + } + + return { + contents, + loader: 'ts', + }; + }); + } + }, + }; +} + +function replaceWorkerFactory(source, workerBundle) { + const factoryPattern = createWorkerFactoryPattern(workerBundle.factoryName, workerBundle.nextStatement); + + return source.replace(factoryPattern, createWorkerFactoryReplacement(workerBundle)); +} + +function createWorkerFactoryPattern(factoryName, nextStatement) { + return new RegExp( + `let ${escapeRegExp(factoryName)}: \\(\\) => Worker;\\s*try \\{[\\s\\S]*?\\};?\\s*${escapeRegExp(nextStatement)}`, + ); +} + +function createWorkerFactoryReplacement({ factoryName, blobUrlName, fileName, nextStatement }) { + return `let ${factoryName}: () => Worker; +let ${blobUrlName}: string | undefined; +${factoryName} = () => { + const workerUrl = new URL("./${fileName}", import.meta.url).href; + + if (!${blobUrlName}) { + const source = \`import \${JSON.stringify(workerUrl)};\`; + ${blobUrlName} = URL.createObjectURL(new Blob([source], { type: "text/javascript" })); + } + + return new Worker(${blobUrlName}, { type: "module" }); +}; +${nextStatement}`; +} + +function textureLoaderPatchPlugin() { + return { + name: 'aholo-texture-loader-patch', + setup(buildContext) { + buildContext.onLoad( + { filter: /loaders[\\/]texture-loader[\\/]ktx2[\\/]basis[\\/]wasm[\\/]basis_transcoder\.js$/ }, + async args => { + const source = await readFile(args.path, 'utf8'); + const nodeBranchShim = + 'var fs={readFileSync:function(){throw new Error("Node file loading is not available in the browser build.");}};'; + const contents = source.replace(/var fs=require\("fs"\);/, nodeBranchShim); + + if (contents === source) { + throw new Error('Unable to patch basis_transcoder.js node file-system branch.'); + } + + return { + contents, + loader: 'js', + }; + }, + ); + }, + }; +} + +function dracoLoaderPatchPlugin() { + return { + name: 'aholo-draco-loader-patch', + setup(buildContext) { + buildContext.onResolve({ filter: /draco_decoder\.wasm$/ }, args => ({ + path: resolve(dirname(args.importer), 'draco_decoder.wasm.js'), + })); + + buildContext.onLoad({ filter: /draco_decoder_wrapper\.js$/ }, async args => { + const source = await readFile(args.path, 'utf8'); + const nodeBranchShim = `var Za={readFileSync:function(){throw new Error("Node file loading is not available in the browser build.");},readFile:function(e,b,c){c(new Error("Node file loading is not available in the browser build."));}},qa={dirname:function(){return""},normalize:function(e){return e}};`; + const contents = source.replace(/var Za=require\("fs"\),qa=require\("path"\);/, nodeBranchShim); + + if (contents === source) { + throw new Error('Unable to patch draco_decoder_wrapper.js node file-system branch.'); + } + + return { + contents, + loader: 'js', + }; + }); + }, + }; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/scripts/check-content.mjs b/scripts/check-content.mjs new file mode 100644 index 0000000..0009a9c --- /dev/null +++ b/scripts/check-content.mjs @@ -0,0 +1,465 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, extname, join, resolve } from 'node:path'; +import { + formatWorkspacePath as formatPath, + isInsideDir, + listFiles, + readJsonFile, + readWebsiteLocales, + statSafe, + workspaceRoot, +} from './package-utils.mjs'; + +const root = workspaceRoot; +const websiteSource = resolve(root, 'website/src'); +const manualRoot = join(websiteSource, 'content/manual'); +const manualAssetRoot = join(manualRoot, 'assets'); +const examplesRoot = join(websiteSource, 'content/examples'); +const apiManifestPath = resolve(root, 'website/.generated/api/manifest.ts'); +const bannedLinkPattern = /(?:https?:\/\/go\.|go\.\/|cf\.qunhequnhe|pages\/viewpage|display\/EGS)/i; +const imagePattern = /!\[[^\]]*]\(([^)]+)\)/g; +const markdownLinkPattern = /(^|[^!])\[[^\]]*]\(([^)]+)\)/g; +const htmlHrefPattern = /\bhref=(["'])([^"']+)\1/g; +const apiReferencePrefix = 'api:'; + +const errors = []; +const locales = readWebsiteLocales(); +const defaultLocale = locales[0]; +const apiEntriesBySymbol = createApiEntriesBySymbol(readApiManifest()); + +checkManualContent(); +checkExamples(); +checkManualImages(); +checkManualLinks(); +checkBannedLinks(); + +if (errors.length > 0) { + console.error(`[content-check] Found ${errors.length} issue(s):`); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); +} + +console.log(`[content-check] OK: ${locales.length} locales, manual pages, examples, links, and assets are consistent.`); + +function checkManualContent() { + const manualByLocale = new Map(); + + for (const locale of locales) { + const localeDir = join(manualRoot, locale); + + if (!existsSync(localeDir)) { + errors.push(`Missing manual locale directory: ${formatPath(localeDir)}`); + continue; + } + + const entries = new Map(); + for (const fileName of readdirSync(localeDir) + .filter(file => file.endsWith('.md')) + .sort()) { + const slug = fileName.replace(/\.md$/, ''); + const filePath = join(localeDir, fileName); + const content = readFileSync(filePath, 'utf8'); + const parsed = parseMarkdown(filePath, content); + + if (slug.includes('tranform')) { + errors.push(`Manual slug contains misspelling "tranform": ${formatPath(filePath)}`); + } + + if (content.trim() === '') { + errors.push(`Manual page is empty: ${formatPath(filePath)}`); + } + + if (!parsed.frontmatter.title) { + errors.push(`Manual page is missing frontmatter title: ${formatPath(filePath)}`); + } + + if (!parsed.frontmatter.description) { + errors.push(`Manual page is missing frontmatter description: ${formatPath(filePath)}`); + } + + if (!Number.isFinite(Number(parsed.frontmatter.order))) { + errors.push(`Manual page is missing numeric frontmatter order: ${formatPath(filePath)}`); + } + + if (parsed.body.trim() === '') { + errors.push(`Manual page has no body content: ${formatPath(filePath)}`); + } + + entries.set(slug, { + filePath, + headingSignature: getHeadingSignature(content), + }); + } + + manualByLocale.set(locale, entries); + } + + const baseEntries = manualByLocale.get(defaultLocale); + if (!baseEntries) { + return; + } + + const baseSlugs = [...baseEntries.keys()].sort(); + + for (const locale of locales.slice(1)) { + const localizedEntries = manualByLocale.get(locale); + if (!localizedEntries) { + continue; + } + + const localizedSlugs = [...localizedEntries.keys()].sort(); + const missing = baseSlugs.filter(slug => !localizedEntries.has(slug)); + const extra = localizedSlugs.filter(slug => !baseEntries.has(slug)); + + for (const slug of missing) { + errors.push(`Manual page "${slug}" exists in ${defaultLocale} but is missing in ${locale}.`); + } + + for (const slug of extra) { + errors.push(`Manual page "${slug}" exists in ${locale} but is missing in ${defaultLocale}.`); + } + + for (const slug of baseSlugs.filter(item => localizedEntries.has(item))) { + const baseSignature = baseEntries.get(slug).headingSignature; + const localizedSignature = localizedEntries.get(slug).headingSignature; + + if (baseSignature !== localizedSignature) { + errors.push( + `Manual heading depth structure differs for "${slug}" between ${defaultLocale} (${baseSignature}) and ${locale} (${localizedSignature}).`, + ); + } + } + } +} + +function checkBannedLinks() { + const scanTargets = [ + join(root, 'README.md'), + join(root, 'docs'), + join(root, 'packages/renderer/README.md'), + join(root, 'website/src/content'), + ]; + + for (const target of scanTargets) { + const files = statSafe(target)?.isDirectory() ? listFiles(target) : [target]; + + for (const filePath of files.filter(file => ['.md', '.mdx'].includes(extname(file).toLowerCase()))) { + if (!existsSync(filePath)) { + continue; + } + + const content = readFileSync(filePath, 'utf8'); + + if (bannedLinkPattern.test(content)) { + errors.push(`Content contains an internal or invalid link: ${formatPath(filePath)}`); + } + } + } +} + +function checkExamples() { + if (!existsSync(examplesRoot)) { + errors.push(`Missing examples directory: ${formatPath(examplesRoot)}`); + return; + } + + const files = readdirSync(examplesRoot); + const jsonSlugs = files + .filter(file => file.endsWith('.json')) + .map(file => file.replace(/\.json$/, '')) + .sort(); + const sourceSlugs = files + .filter(file => file.endsWith('.ts')) + .map(file => file.replace(/\.ts$/, '')) + .sort(); + + for (const slug of jsonSlugs.filter(item => !sourceSlugs.includes(item))) { + errors.push(`Example metadata is missing a TypeScript source file: ${slug}.json`); + } + + for (const slug of sourceSlugs.filter(item => !jsonSlugs.includes(item))) { + errors.push(`Example TypeScript source is missing JSON metadata: ${slug}.ts`); + } + + for (const slug of jsonSlugs) { + const filePath = join(examplesRoot, `${slug}.json`); + const metadata = readJson(filePath); + + if (!metadata) { + continue; + } + + for (const locale of locales) { + if (!metadata.title?.[locale]) { + errors.push(`Example "${slug}" is missing title for ${locale}.`); + } + } + } +} + +function checkManualImages() { + const usedImages = new Set(); + + for (const filePath of listFiles(manualRoot).filter(file => file.endsWith('.md'))) { + const content = readFileSync(filePath, 'utf8'); + + for (const match of content.matchAll(imagePattern)) { + const target = parseMarkdownTarget(match[1]); + + if (!target || isExternalReference(target.href)) { + continue; + } + + if (target.href.startsWith('/manual/')) { + errors.push( + `Manual image should use a local relative path for editor previews: ${target.href} referenced by ${formatPath(filePath)}`, + ); + continue; + } + + if (!isLocalReference(target.href)) { + continue; + } + + const { pathname } = splitPathnameAndTrailing(target.href); + const imagePath = resolve(dirname(filePath), decodeURIComponent(pathname)); + + if (!isInsideDir(manualAssetRoot, imagePath)) { + errors.push( + `Manual image must live under website/src/content/manual/assets: ${target.href} referenced by ${formatPath(filePath)}`, + ); + continue; + } + + usedImages.add(formatManualAssetPath(imagePath)); + + if (!existsSync(imagePath)) { + errors.push(`Manual image is missing: ${target.href} referenced by ${formatPath(filePath)}`); + continue; + } + + if (!isImageFile(imagePath)) { + errors.push( + `Manual image reference is not an image file: ${target.href} referenced by ${formatPath(filePath)}`, + ); + } + } + } + + for (const imagePath of listFiles(manualAssetRoot).filter(isImageFile)) { + const normalized = formatManualAssetPath(imagePath); + + if (!usedImages.has(normalized)) { + errors.push(`Manual image is not referenced by any manual page: ${normalized}`); + } + } +} + +function checkManualLinks() { + for (const filePath of listFiles(manualRoot).filter(file => file.endsWith('.md'))) { + const content = readFileSync(filePath, 'utf8'); + + for (const target of getManualLinkTargets(content)) { + const parsed = parseMarkdownTarget(target); + + if (!parsed) { + continue; + } + + if (isApiReference(parsed.href)) { + checkManualApiLink(parsed.href, target, filePath); + continue; + } + + if (!isLocalReference(parsed.href)) { + continue; + } + + const { pathname } = splitPathnameAndTrailing(parsed.href); + + if (extname(pathname).toLowerCase() !== '.md') { + continue; + } + + const linkedPath = resolve(dirname(filePath), decodeURIComponent(pathname)); + + if (!isInsideDir(manualRoot, linkedPath)) { + errors.push( + `Manual Markdown link must point to a manual page: ${target} referenced by ${formatPath(filePath)}`, + ); + continue; + } + + if (!existsSync(linkedPath)) { + errors.push(`Manual page link is missing: ${target} referenced by ${formatPath(filePath)}`); + } + } + } +} + +function checkManualApiLink(href, target, filePath) { + const { pathname } = splitPathnameAndTrailing(href); + const symbol = decodeURIComponent(pathname.slice(apiReferencePrefix.length)).trim(); + + if (!symbol) { + errors.push(`Manual API link is missing a symbol: ${target} referenced by ${formatPath(filePath)}`); + return; + } + + const entries = apiEntriesBySymbol.get(symbol) ?? []; + + if (entries.length === 0) { + errors.push(`Manual API link target not found: ${target} referenced by ${formatPath(filePath)}`); + return; + } + + if (entries.length > 1) { + errors.push( + `Manual API link target is ambiguous: ${target} referenced by ${formatPath(filePath)}. Use Namespace.Symbol.`, + ); + } +} + +function getManualLinkTargets(content) { + const targets = []; + + for (const match of content.matchAll(markdownLinkPattern)) { + targets.push(match[2]); + } + + for (const match of content.matchAll(htmlHrefPattern)) { + targets.push(match[2]); + } + + return targets; +} + +function parseMarkdown(filePath, content) { + if (!content.startsWith('---')) { + return { + frontmatter: {}, + body: content, + }; + } + + const endMatch = content.slice(3).match(/\r?\n---\r?\n/); + if (!endMatch || endMatch.index === undefined) { + errors.push(`Manual page has unterminated frontmatter: ${formatPath(filePath)}`); + return { + frontmatter: {}, + body: content, + }; + } + + const frontmatterEnd = 3 + endMatch.index; + const delimiterLength = endMatch[0].length; + const frontmatterText = content.slice(3, frontmatterEnd); + const body = content.slice(frontmatterEnd + delimiterLength); + const frontmatter = {}; + + for (const line of frontmatterText.split(/\r?\n/)) { + const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + + if (!match) { + continue; + } + + frontmatter[match[1]] = match[2].replace(/^["']|["']$/g, '').trim(); + } + + return { + frontmatter, + body, + }; +} + +function getHeadingSignature(content) { + return content + .split(/\r?\n/) + .map(line => line.match(/^(#{2,6})\s+\S/)) + .filter(Boolean) + .map(match => match[1].length) + .join(','); +} + +function readJson(filePath) { + try { + return readJsonFile(filePath, 'example metadata'); + } catch (error) { + errors.push(`Unable to parse JSON at ${formatPath(filePath)}: ${error.message}`); + return undefined; + } +} + +function readApiManifest() { + const source = readFileSync(apiManifestPath, 'utf8'); + const match = source.match(/^export const apiManifest = ([\s\S]*?) as const;\s*export type /); + + if (!match) { + throw new Error(`Unable to parse API manifest at ${formatPath(apiManifestPath)}.`); + } + + return JSON.parse(match[1]); +} + +function createApiEntriesBySymbol(manifest) { + const entriesBySymbol = new Map(); + + for (const entry of manifest.entries ?? []) { + addApiEntrySymbol(entriesBySymbol, entry.title, entry); + addApiEntrySymbol(entriesBySymbol, `${entry.namespace}.${entry.title}`, entry); + } + + return entriesBySymbol; +} + +function addApiEntrySymbol(entriesBySymbol, symbol, entry) { + const entries = entriesBySymbol.get(symbol); + + if (entries) { + entries.push(entry); + return; + } + + entriesBySymbol.set(symbol, [entry]); +} + +function isImageFile(filePath) { + return ['.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp'].includes(extname(filePath).toLowerCase()); +} + +function parseMarkdownTarget(target) { + const trimmed = target.trim(); + const match = trimmed.match(/^(\S+)(.*)$/s); + + return match ? { href: match[1], suffix: match[2] } : undefined; +} + +function isExternalReference(href) { + return /^[a-z][a-z\d+.-]*:/i.test(href); +} + +function isApiReference(href) { + const { pathname } = splitPathnameAndTrailing(href); + + return pathname.startsWith(apiReferencePrefix); +} + +function isLocalReference(href) { + return !href.startsWith('/') && !href.startsWith('#') && !isExternalReference(href); +} + +function splitPathnameAndTrailing(href) { + const match = href.match(/^([^?#]*)([?#].*)?$/s); + + return { + pathname: match?.[1] ?? href, + trailing: match?.[2] ?? '', + }; +} + +function formatManualAssetPath(filePath) { + return formatPath(filePath).replace(/^website\/src\/content\/manual\//, 'manual/'); +} diff --git a/scripts/clean-package.mjs b/scripts/clean-package.mjs new file mode 100644 index 0000000..2561600 --- /dev/null +++ b/scripts/clean-package.mjs @@ -0,0 +1,46 @@ +import { rm } from 'node:fs/promises'; +import { dirname, relative, resolve } from 'node:path'; +import { + assertInsideDir, + readPackageJson, + resolvePackagePath, + resolvePackageRoot, + toPosixPath, +} from './package-utils.mjs'; + +const packageRoot = resolvePackageRoot(); +const packageJson = readPackageJson(packageRoot); +const packageName = packageJson.name ?? packageRoot; +const targetDirs = getCleanTargetDirs(packageJson).map(target => + resolvePackagePath(packageRoot, target, 'clean target'), +); + +for (const targetDir of [...new Set(targetDirs.map(dir => resolve(dir)))]) { + assertInsideDir(packageRoot, targetDir, 'Clean target'); + + if (resolve(targetDir) === resolve(packageRoot)) { + throw new Error(`Refusing to clean the package root: ${targetDir}`); + } + + await rm(targetDir, { recursive: true, force: true }); + console.log(`[package-clean] Removed ${formatPackagePath(targetDir)} for ${packageName}.`); +} + +function getCleanTargetDirs(metadata) { + const configuredTargets = metadata.aholoClean?.targets; + + if (Array.isArray(configuredTargets) && configuredTargets.length > 0) { + return configuredTargets; + } + + const outputDirs = [metadata.main, metadata.module, metadata.types, metadata.typings] + .filter(value => typeof value === 'string' && value.trim() !== '') + .map(filePath => dirname(filePath)) + .filter(dir => dir !== '.'); + + return outputDirs.length > 0 ? [...new Set(outputDirs)] : ['dist']; +} + +function formatPackagePath(filePath) { + return toPosixPath(relative(packageRoot, filePath)); +} diff --git a/scripts/ensure-submodules.mjs b/scripts/ensure-submodules.mjs new file mode 100644 index 0000000..53e94be --- /dev/null +++ b/scripts/ensure-submodules.mjs @@ -0,0 +1,99 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { runCommand, runCommandOrExit, workspaceRoot } from './package-utils.mjs'; + +const rootDir = workspaceRoot; + +if (process.env.AHOLO_SKIP_SUBMODULE_UPDATE === '1') { + console.log('[submodules] Skipping because AHOLO_SKIP_SUBMODULE_UPDATE=1.'); + process.exit(0); +} + +if (!existsSync(resolve(rootDir, '.git'))) { + console.log('[submodules] Skipping submodule check outside a Git checkout.'); + process.exit(0); +} + +const unrecordedSubmodules = listUnrecordedSubmoduleCommits(); + +if (unrecordedSubmodules.length > 0) { + console.log('[submodules] Skipping update because these submodules have unrecorded commits:'); + + for (const submodule of unrecordedSubmodules) { + console.log(` - ${submodule}`); + } + + console.log('[submodules] Run git add first if the parent repo should record these commits.'); + process.exit(0); +} + +runCommandOrExit('git', ['submodule', 'update', '--init', '--recursive'], { + cwd: rootDir, + env: gitEnv(), + label: 'git submodule update --init --recursive', +}); + +console.log('[submodules] Ready.'); + +function listUnrecordedSubmoduleCommits() { + return listSubmodulePaths().filter(hasUnrecordedSubmoduleCommit); +} + +function listSubmodulePaths() { + if (!existsSync(resolve(rootDir, '.gitmodules'))) { + return []; + } + + const result = runCommandOrExit('git', ['config', '--file', '.gitmodules', '--get-regexp', 'path'], { + cwd: rootDir, + encoding: 'utf8', + env: gitEnv(), + label: 'git config --file .gitmodules --get-regexp path', + stdio: 'pipe', + }); + + return (result.stdout ?? '') + .split(/\r?\n/) + .filter(Boolean) + .map(line => line.replace(/^submodule\..*\.path\s+/, '')); +} + +function hasUnrecordedSubmoduleCommit(submodulePath) { + const result = runCommand('git', ['diff', '--quiet', '--ignore-submodules=dirty', '--', submodulePath], { + cwd: rootDir, + encoding: 'utf8', + env: gitEnv(), + stdio: 'pipe', + }); + + if (result.status === 0) { + return false; + } + + if (result.status === 1) { + return true; + } + + console.error(`[submodules] Unable to inspect submodule status: ${submodulePath}`); + + if (result.error) { + console.error(result.error); + } + + if (result.stderr) { + console.error(result.stderr); + } + + process.exit(result.status ?? 1); +} + +function gitEnv() { + const env = { ...process.env }; + const windowsOpenSsh = 'C:\\Windows\\System32\\OpenSSH\\ssh.exe'; + + if (process.platform === 'win32' && !env.GIT_SSH_COMMAND && existsSync(windowsOpenSsh)) { + env.GIT_SSH_COMMAND = windowsOpenSsh.replaceAll('\\', '/'); + } + + return env; +} diff --git a/scripts/generate-api-docs.mjs b/scripts/generate-api-docs.mjs new file mode 100644 index 0000000..0ab571a --- /dev/null +++ b/scripts/generate-api-docs.mjs @@ -0,0 +1,865 @@ +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, extname, join, relative, resolve } from 'node:path'; +import ts from 'typescript'; +import { + Application, + BaseRouter, + DefaultTheme, + JSX, + PageEvent, + PageKind, + ReflectionKind, + RendererEvent, +} from 'typedoc'; +import { readWebsiteLocales, toPosixPath, workspaceRoot } from './package-utils.mjs'; + +const root = workspaceRoot; +const rendererDeclaration = resolve(root, 'packages/renderer/dist/index.d.ts'); +const tempOutputDir = resolve(root, 'node_modules/.cache/aholo-api-docs'); +const contentRoot = resolve(root, 'website/.generated/api'); +const locales = readWebsiteLocales(); + +const apiKindByReflectionKind = new Map([ + [ReflectionKind.Class, 'class'], + [ReflectionKind.Enum, 'enumeration'], + [ReflectionKind.Function, 'function'], + [ReflectionKind.Interface, 'interface'], + [ReflectionKind.TypeAlias, 'type'], + [ReflectionKind.Variable, 'variable'], +]); + +const folderByKind = { + class: 'classes', + enumeration: 'enumerations', + function: 'functions', + interface: 'interfaces', + type: 'type-aliases', + variable: 'variables', +}; + +const kindLabels = { + class: 'Class', + enumeration: 'Enumeration', + function: 'Function', + interface: 'Interface', + type: 'Type Alias', + variable: 'Variable', +}; + +const namespaceOrder = ['core', 'events', 'animation', 'splat-loader', 'draco-loader', 'gltf-loader', 'splat-utils']; + +const materialAliasDocs = new Map([ + ['MeshBasicMaterial', { base: 'BaseMeshBasicMaterial', parameters: 'MeshBasicMaterialParameters' }], + ['MeshPhongMaterial', { base: 'BaseMeshPhongMaterial', parameters: 'MeshPhongMaterialParameters' }], + ['SpriteMaterial', { base: 'BaseSpriteMaterial', parameters: 'SpriteMaterialParameters' }], +]); + +await rm(tempOutputDir, { recursive: true, force: true }); +await mkdir(tempOutputDir, { recursive: true }); +await mkdir(contentRoot, { recursive: true }); + +const apiNamespaces = await createApiNamespacesFromRendererDeclaration(); +const namespaceBySlug = new Map(apiNamespaces.map(namespace => [namespace.slug, namespace])); +const categoryLabels = Object.fromEntries( + apiNamespaces.map(({ category, categoryLabel }) => [category, categoryLabel]), +); +const renderedPages = new Map(); +const typedocPageOrder = new Map(); + +class AholoApiRouter extends BaseRouter { + getPageKind(target) { + if (target?.kindOf?.(ReflectionKind.SomeModule)) { + return PageKind.Reflection; + } + + return getApiKind(target) ? PageKind.Reflection : undefined; + } + + getIdealBaseName(reflection) { + const apiKind = getApiKind(reflection); + const namespace = getReflectionNamespace(reflection); + + if (!apiKind || !namespace) { + return toKebabCase(reflection.name); + } + + const folder = isMaterialAliasDocReflection(reflection) ? folderByKind.variable : folderByKind[apiKind]; + + return [namespace.slug, folder, toKebabCase(reflection.name)].join('/'); + } +} + +class AholoApiFragmentTheme extends DefaultTheme { + render(page) { + if (!page.isReflectionEvent()) { + return ''; + } + + const template = { + [PageKind.Reflection]: this.reflectionTemplate, + [PageKind.Document]: this.documentTemplate, + [PageKind.Index]: this.indexTemplate, + [PageKind.Hierarchy]: this.hierarchyTemplate, + }[page.pageKind]; + + return template ? JSX.renderElement(template(page)) : ''; + } +} + +const app = await Application.bootstrapWithPlugins({ + entryPoints: [toPosixPath(rendererDeclaration)], + out: toPosixPath(tempOutputDir), + readme: 'none', + router: 'aholo-api', + theme: 'aholo-api-fragment', + disableSources: true, + excludeInternal: true, + excludePrivate: true, + excludeProtected: true, + skipErrorChecking: true, + sort: ['source-order'], + validation: false, + hideGenerator: true, + includeHierarchySummary: false, +}); + +app.renderer.defineRouter('aholo-api', AholoApiRouter); +app.renderer.defineTheme('aholo-api-fragment', AholoApiFragmentTheme); +app.renderer.on(RendererEvent.BEGIN, event => { + event.pages.forEach((page, index) => { + typedocPageOrder.set(toPosixPath(page.url), index + 1); + }); +}); +app.renderer.on(PageEvent.END, event => { + const sourcePath = toPosixPath(relative(tempOutputDir, event.filename)); + const metadata = createMetadata(event, sourcePath); + + if (!metadata) { + return; + } + + renderedPages.set(sourcePath, metadata); +}); + +const project = await app.convert(); + +if (!project) { + throw new Error('TypeDoc failed to convert the renderer entry.'); +} + +await app.generateOutputs(project); + +const entries = [...renderedPages.values()].sort(compareEntries).map((entry, index) => ({ + ...entry, + order: index + 1, +})); +const targetPaths = new Map(entries.map(entry => [entry.sourcePath, `${entry.slug}.html`])); +const expectedOutputPaths = new Set(); + +await writeApiManifest(entries); + +for (const entry of entries) { + for (const locale of locales) { + const outputPath = resolve(contentRoot, locale, `${entry.slug}.html`); + const content = `${rewriteHtmlLinks(entry.html, entry.sourcePath, targetPaths, locale).trim()}\n`; + + expectedOutputPaths.add(resolve(outputPath)); + await writeFileIfChanged(outputPath, content); + } +} + +await removeStaleGeneratedFiles(expectedOutputPaths); +await pruneEmptyDirectories(contentRoot); +await rm(tempOutputDir, { recursive: true, force: true }); + +console.log(`[api-docs] Generated ${entries.length} API HTML pages for ${locales.join(', ')}.`); + +async function createApiNamespacesFromRendererDeclaration() { + let declarationSource; + + try { + declarationSource = await readFile(rendererDeclaration, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') { + throw new Error( + `Renderer declaration not found at ${toPosixPath(relative(root, rendererDeclaration))}. Run pnpm build:renderer first.`, + ); + } + + throw error; + } + + const sourceFile = ts.createSourceFile(rendererDeclaration, declarationSource, ts.ScriptTarget.Latest, true); + const declaredNamespaces = collectDeclaredNamespaces(sourceFile); + const namespaces = [ + { + slug: 'core', + name: 'Core', + category: 'core', + categoryLabel: 'Core', + namespaceLabel: 'Core', + }, + ]; + const knownSlugs = new Set(namespaces.map(namespace => namespace.slug)); + + for (const namespaceLabel of getExportedNamespaceLabels(sourceFile, declaredNamespaces)) { + const namespace = createNamespaceEntry(namespaceLabel); + + if (knownSlugs.has(namespace.slug)) { + continue; + } + + namespaces.push(namespace); + knownSlugs.add(namespace.slug); + } + + return orderApiNamespaces(namespaces); +} + +function collectDeclaredNamespaces(sourceFile) { + const namespaces = new Set(); + + for (const statement of sourceFile.statements) { + if (ts.isModuleDeclaration(statement) && ts.isIdentifier(statement.name)) { + namespaces.add(statement.name.text); + } + } + + return namespaces; +} + +function getExportedNamespaceLabels(sourceFile, declaredNamespaces) { + const labels = []; + + for (const statement of sourceFile.statements) { + if (ts.isModuleDeclaration(statement) && hasExportModifier(statement) && ts.isIdentifier(statement.name)) { + labels.push(statement.name.text); + continue; + } + + if ( + !ts.isExportDeclaration(statement) || + !statement.exportClause || + !ts.isNamedExports(statement.exportClause) + ) { + continue; + } + + for (const element of statement.exportClause.elements) { + const localName = element.propertyName?.text ?? element.name.text; + + if (declaredNamespaces.has(localName)) { + labels.push(element.name.text); + } + } + } + + return labels; +} + +function orderApiNamespaces(namespaces) { + const orderBySlug = new Map(namespaceOrder.map((slug, index) => [slug, index])); + + return namespaces + .map((namespace, sourceIndex) => ({ namespace, sourceIndex })) + .sort((left, right) => { + const leftOrder = orderBySlug.get(left.namespace.slug) ?? Number.POSITIVE_INFINITY; + const rightOrder = orderBySlug.get(right.namespace.slug) ?? Number.POSITIVE_INFINITY; + + return leftOrder - rightOrder || left.sourceIndex - right.sourceIndex; + }) + .map(({ namespace }) => namespace); +} + +function createNamespaceEntry(namespaceLabel) { + return { + slug: toKebabCase(namespaceLabel), + name: namespaceLabel, + category: toKebabCase(namespaceLabel), + categoryLabel: namespaceLabel, + namespaceLabel, + }; +} + +function hasExportModifier(node) { + return Boolean(node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)); +} + +async function writeFileIfChanged(filePath, content) { + try { + if ((await readFile(filePath, 'utf8')) === content) { + return; + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, content); +} + +async function listGeneratedContentFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const filePath = join(directory, entry.name); + + if (entry.isDirectory()) { + files.push(...(await listGeneratedContentFiles(filePath))); + continue; + } + + if (entry.isFile() && ['.html', '.md'].includes(extname(entry.name))) { + files.push(filePath); + } + } + + return files; +} + +async function removeStaleGeneratedFiles(expectedOutputPaths) { + for (const filePath of await listGeneratedContentFiles(contentRoot)) { + if (!expectedOutputPaths.has(resolve(filePath))) { + await rm(filePath, { force: true }); + } + } +} + +async function pruneEmptyDirectories(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + await pruneEmptyDirectories(join(directory, entry.name)); + } + } + + if (resolve(directory) === resolve(contentRoot)) { + return; + } + + if ((await readdir(directory)).length === 0) { + await rm(directory, { recursive: true, force: true }); + } +} + +async function writeApiManifest(entries) { + const categories = []; + const categoriesBySlug = new Map(); + const namespaces = []; + const namespaceKeys = new Set(); + + for (const namespace of apiNamespaces) { + if (!categoriesBySlug.has(namespace.category)) { + const category = { + category: namespace.category, + label: namespace.categoryLabel, + namespaces: [], + }; + + categoriesBySlug.set(namespace.category, category); + categories.push(category); + } + + const namespaceKey = `${namespace.category}:${namespace.name}`; + + if (namespaceKeys.has(namespaceKey)) { + continue; + } + + namespaceKeys.add(namespaceKey); + + const namespaceEntry = { + namespace: namespace.name, + label: namespace.namespaceLabel, + category: namespace.category, + slug: namespace.slug, + }; + + namespaces.push(namespaceEntry); + categoriesBySlug.get(namespace.category)?.namespaces.push(namespaceEntry); + } + + const kinds = [...new Set(entries.map(entry => entry.kind))].sort(); + const manifest = { + categories, + namespaces, + kinds, + entries: entries.map(({ html, sourcePath, sourceOrder, ...entry }) => entry), + }; + const content = [ + 'export const apiManifest = ', + JSON.stringify(manifest, null, 2), + ' as const;\n\n', + 'export type ApiCategory = (typeof apiManifest.categories)[number]["category"];\n', + 'export type ApiNamespace = (typeof apiManifest.namespaces)[number]["namespace"];\n', + 'export type ApiKind = (typeof apiManifest.kinds)[number];\n', + 'export type ApiManifestEntry = (typeof apiManifest.entries)[number];\n', + 'export type ApiHeading = ApiManifestEntry["headings"][number];\n', + ].join(''); + + await writeFileIfChanged(resolve(contentRoot, 'manifest.ts'), content); +} + +function createMetadata(event, sourcePath) { + const reflection = event.model; + const kind = getApiKind(reflection); + const namespace = getReflectionNamespace(reflection); + const html = cleanTypeDocHtml(event.contents ?? ''); + + if (!kind || !namespace || !sourcePath.endsWith('.html')) { + return undefined; + } + + const category = namespace.category; + const title = reflection.name; + const slug = sourcePath.replace(/\.html$/, ''); + + return { + sourcePath, + slug, + title, + description: + getReflectionDescription(reflection) || `${namespace.name}.${title} exported from @manycore/aholo-viewer.`, + sourceOrder: typedocPageOrder.get(sourcePath) ?? Number.POSITIVE_INFINITY, + kind, + kindLabel: kindLabels[kind], + namespace: namespace.name, + namespaceLabel: namespace.namespaceLabel, + category, + categoryLabel: categoryLabels[category], + signature: getReflectionSignature(reflection, kind), + headings: getApiHtmlHeadings(html, event), + html, + }; +} + +function getApiKind(reflection) { + for (const [reflectionKind, apiKind] of apiKindByReflectionKind) { + if (reflection?.kindOf?.(reflectionKind)) { + return apiKind; + } + } + + return undefined; +} + +function isMaterialAliasDocReflection(reflection) { + return reflection?.kindOf?.(ReflectionKind.Class) && materialAliasDocs.has(reflection.name); +} + +function getReflectionNamespace(reflection) { + let topLevel = reflection; + + while (topLevel?.parent && !topLevel.parent.isProject?.()) { + topLevel = topLevel.parent; + } + + const slug = toKebabCase(topLevel?.name ?? ''); + + return namespaceBySlug.get(slug) ?? namespaceBySlug.get('core'); +} + +function getReflectionDescription(reflection) { + return ( + displayPartsToText(reflection.comment?.summary) || + displayPartsToText(getPrimarySignature(reflection)?.comment?.summary) || + undefined + ); +} + +function getReflectionSignature(reflection, kind) { + const signature = kind === 'class' ? getConstructorSignature(reflection) : getPrimarySignature(reflection); + + if (signature) { + const params = (signature.parameters ?? []) + .map( + parameter => + `${parameter.name}${parameter.flags?.isOptional ? '?' : ''}${ + parameter.type ? `: ${parameter.type.toString()}` : '' + }`, + ) + .join(', '); + const returnType = signature.type?.toString(); + const name = kind === 'class' ? `new ${reflection.name}` : reflection.name; + + return `${name}(${params})${returnType ? `: ${returnType}` : ''}`; + } + + if (kind === 'class') { + return `class ${reflection.name}`; + } + + if (kind === 'enumeration') { + return `enum ${reflection.name}`; + } + + if (kind === 'interface') { + return `interface ${reflection.name}`; + } + + if (kind === 'type') { + return `type ${reflection.name}${reflection.type ? ` = ${reflection.type.toString()}` : ''}`; + } + + if (kind === 'variable') { + return `const ${reflection.name}${reflection.type ? `: ${reflection.type.toString()}` : ''}`; + } + + return `${kind} ${reflection.name}`; +} + +function getConstructorSignature(reflection) { + const constructor = reflection.children?.find(child => child.kindOf(ReflectionKind.Constructor)); + + return constructor?.signatures?.[0]; +} + +function getPrimarySignature(reflection) { + return reflection.signatures?.[0] ?? reflection.getSignature ?? reflection.setSignature; +} + +function displayPartsToText(parts) { + const value = parts + ?.map(part => part.text) + .join('') + .replace(/\s+/g, ' ') + .trim(); + + return value || undefined; +} + +function toApiHeading(heading) { + const slug = heading.link?.replace(/^#/, ''); + + if (!slug) { + return undefined; + } + + return { + depth: heading.level ?? 2, + slug, + text: heading.text, + }; +} + +function getApiHtmlHeadings(html, event) { + const htmlHeadings = extractTypeDocHtmlHeadings(html); + + if (htmlHeadings.length > 0) { + return htmlHeadings; + } + + return event.pageHeadings.map(toApiHeading).filter(Boolean); +} + +function compareEntries(left, right) { + if (left.sourceOrder !== right.sourceOrder) { + return left.sourceOrder - right.sourceOrder; + } + + return left.title.localeCompare(right.title); +} + +function rewriteHtmlLinks(content, sourcePath, targetPaths, locale) { + const sourceDir = sourcePath.split('/').slice(0, -1).join('/'); + + return content.replace( + /\bhref=(["'])(?![a-z][a-z0-9+.-]*:|#|\/)([^"']+?\.html)(#[^"']*)?\1/gi, + (match, quote, href, hash = '') => { + const targetSourcePath = normalizeRelativePath(sourceDir, href); + const targetPath = targetPaths.get(targetSourcePath); + + if (!targetPath) { + return match; + } + + const routePath = targetPath.replace(/\.html$/, ''); + + return `href=${quote}/${locale}/api/${routePath}/${hash}${quote}`; + }, + ); +} + +function cleanTypeDocHtml(content) { + const normalized = stripTypeDocOptionalTags(stripTypeDocAssetIcons(content).replace(/\u00a0/g, ' ')); + + return addTypeDocGroupHeadingIds( + removeTypeDocTypeDeclarationSignatures(inlineTypeDocDefaultValues(removeTypeDocIndexGroup(normalized))), + ); +} + +function stripTypeDocAssetIcons(content) { + return content.replace(/]*>\s*<\/use>\s*<\/svg>/g, ''); +} + +function stripTypeDocOptionalTags(content) { + return content.replace(/Optional<\/code>/g, ''); +} + +function inlineTypeDocDefaultValues(content) { + return inlineTypeDocMemberDefaultValues(inlineTypeDocParameterDefaultValues(content)); +} + +function inlineTypeDocMemberDefaultValues(content) { + return replaceTypeDocBlocks( + content, + /]*class=(["'])[^"']*\btsd-member\b[^"']*\1[^>]*>/g, + 'section', + inlineTypeDocDefaultValueInBlock, + ); +} + +function inlineTypeDocParameterDefaultValues(content) { + return replaceTypeDocBlocks( + content, + /]*class=(["'])[^"']*\btsd-parameter\b[^"']*\1[^>]*>/g, + 'li', + inlineTypeDocDefaultValueInBlock, + ); +} + +function replaceTypeDocBlocks(content, pattern, tagName, replacer) { + let cleaned = ''; + let cursor = 0; + let match; + + while ((match = pattern.exec(content))) { + if (match.index < cursor) { + continue; + } + + const end = findMatchingElementEnd(content, match.index, tagName); + + if (end === -1) { + continue; + } + + cleaned += content.slice(cursor, match.index); + cleaned += replacer(content.slice(match.index, end)); + cursor = end; + pattern.lastIndex = end; + } + + return cleaned + content.slice(cursor); +} + +function removeTypeDocTypeDeclarationSignatures(content) { + return replaceTypeDocBlocks( + content, + /]*class=(["'])[^"']*\btsd-member\b[^"']*\1[^>]*>/g, + 'section', + block => + block.includes('
') + ? block + .replace(/
)\s*

Type Declaration<\/h4>/, '$1') + : block, + ); +} + +function inlineTypeDocDefaultValueInBlock(block) { + const defaultBlock = block.match(/
[\s\S]*?<\/div>/)?.[0]; + + if (!defaultBlock) { + return block; + } + + const defaultValue = extractTypeDocDefaultValue(defaultBlock); + + if (!defaultValue) { + return block; + } + + const blockWithoutDefault = block + .replace(defaultBlock, '') + .replace(/
\s*<\/div>/g, ''); + + return appendTypeDocDefaultValueToTitle(blockWithoutDefault, defaultValue); +} + +function extractTypeDocDefaultValue(defaultBlock) { + const content = defaultBlock + .replace(/^
/, '') + .replace(/<\/div>$/, '') + .replace(//, '') + .trim(); + const paragraphs = [...content.matchAll(/

([\s\S]*?)<\/p>/g)].map(match => match[1].trim()).filter(Boolean); + const inlineCodeMatch = (paragraphs[0] ?? content).match(/]*)?>([\s\S]*?)<\/code>/); + + if (inlineCodeMatch) { + return inlineCodeMatch[1].trim(); + } + + const value = (paragraphs.length > 0 ? paragraphs.join(' ') : content).trim(); + const codeMatch = value.match(/^]*)?>([\s\S]*?)<\/code>$/); + + return (codeMatch?.[1] ?? value).trim(); +} + +function appendTypeDocDefaultValueToTitle(block, defaultValue) { + const defaultHtml = `${defaultValue}`; + const titledBlock = block.replace(/<(h3|h5)\b([^>]*)>([\s\S]*?)<\/\1>/, (match, tagName, attributes, inner) => { + const title = inner.replace( + /(\s*]*class=(["'])[^"']*\btsd-anchor-icon\b[^"']*\2[^>]*><\/a>\s*)$/, + `${defaultHtml}$1`, + ); + + return title === inner + ? `<${tagName}${attributes}>${inner}${defaultHtml}` + : `<${tagName}${attributes}>${title}`; + }); + + if (titledBlock !== block) { + return titledBlock; + } + + return block.replace(/(]*>[\s\S]*?)(<\/span>)/, `$1${defaultHtml}$2`); +} + +function removeTypeDocIndexGroup(content) { + const marker = '

'; + let cleaned = ''; + let cursor = 0; + + while (cursor < content.length) { + const start = content.indexOf(marker, cursor); + + if (start === -1) { + cleaned += content.slice(cursor); + break; + } + + const end = findMatchingSectionEnd(content, start); + + cleaned += content.slice(cursor, start); + cursor = end === -1 ? start + marker.length : end; + } + + return cleaned; +} + +function findMatchingSectionEnd(content, start) { + return findMatchingElementEnd(content, start, 'section'); +} + +function findMatchingElementEnd(content, start, tagName) { + const tagPattern = new RegExp(`]*>`, 'g'); + tagPattern.lastIndex = start; + + let depth = 0; + let match; + + while ((match = tagPattern.exec(content))) { + if (match[0].startsWith(']*\bdata-key=(["'])section-[^"']+\2[^>]*>\s*)

(.*?)<\/h2>/g, + (match, summaryOpen, quote, headingHtml) => { + const baseSlug = toKebabCase(stripHtml(headingHtml)) || 'section'; + const count = usedSlugs.get(baseSlug) ?? 0; + const slug = count === 0 ? baseSlug : `${baseSlug}-${count + 1}`; + + usedSlugs.set(baseSlug, count + 1); + + return `${summaryOpen}

${headingHtml}

`; + }, + ); +} + +function extractTypeDocHtmlHeadings(content) { + return [ + ...content.matchAll( + /]*\bdata-key=(["'])section-[^"']+\1[^>]*>\s*

(.*?)<\/h2>/g, + ), + ] + .map(match => ({ + depth: 2, + index: match.index ?? 0, + slug: match[3], + text: typeDocHeadingText(match[4]), + })) + .concat( + [...content.matchAll(/]*\bid=(["'])([^"']+)\1[^>]*>([\s\S]*?)<\/h3>/g)].map(match => ({ + depth: 3, + index: match.index ?? 0, + slug: match[2], + text: typeDocHeadingText(match[3]), + })), + ) + .filter(heading => heading.text) + .sort((left, right) => left.index - right.index) + .map(({ index, ...heading }) => heading); +} + +function typeDocHeadingText(content) { + return decodeBasicHtml( + stripHtml( + content + .replace(/]*class=(["'])[^"']*\btsd-anchor-icon\b[^"']*\1[^>]*><\/a>/g, '') + .replace(/[\s\S]*?<\/code>/g, '') + .replace(/[\s\S]*?<\/code>/g, '') + .replace(/[\s\S]*?<\/span>/g, '') + .replace(//g, ''), + ), + ); +} + +function stripHtml(content) { + return content.replace(/<[^>]*>/g, '').trim(); +} + +function decodeBasicHtml(content) { + return content + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function normalizeRelativePath(sourceDir, href) { + const stack = sourceDir ? sourceDir.split('/') : []; + + for (const part of href.split('/')) { + if (part === '.' || part === '') { + continue; + } + + if (part === '..') { + stack.pop(); + continue; + } + + stack.push(part); + } + + return stack.join('/'); +} + +function toKebabCase(value) { + return value + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/([A-Za-z])(\d+)/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .toLowerCase(); +} diff --git a/scripts/generate-llm-docs.mjs b/scripts/generate-llm-docs.mjs new file mode 100644 index 0000000..e02a820 --- /dev/null +++ b/scripts/generate-llm-docs.mjs @@ -0,0 +1,661 @@ +#!/usr/bin/env node +/** + * Generates AI-friendly English markdown copies of the built documentation + * under website/dist/llm/. Reads cleanest sources where possible (manual + * markdown, example .ts/.json) and converts generated API HTML fragments + * to markdown. + * + * Output layout: + * website/dist/llm/ + * index.md + * README.md + * manual/{slug}.md, index.md + * api/{category}/{folder}/{name}.md, index.md + * examples/{slug}.md, index.md + * website/dist/llms.txt + */ + +import { existsSync } from 'node:fs'; +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, extname, resolve } from 'node:path'; +import { workspaceRoot } from './package-utils.mjs'; + +const LOCALE = 'en-US'; +const manualSourceRoot = resolve(workspaceRoot, 'website/src/content/manual'); +const examplesSourceRoot = resolve(workspaceRoot, 'website/src/content/examples'); +const generatedApiRoot = resolve(workspaceRoot, 'website/.generated/api'); +const websiteDistRoot = resolve(workspaceRoot, 'website/dist'); +const llmRoot = resolve(workspaceRoot, 'website/dist/llm'); +const llmsTxtPath = resolve(websiteDistRoot, 'llms.txt'); + +await rm(llmRoot, { recursive: true, force: true }); +await mkdir(llmRoot, { recursive: true }); + +const apiManifest = await loadApiManifest(); +const manualEntries = await emitManual(); +const apiEntries = await emitApi(); +const exampleEntries = await emitExamples(); + +await writeFile(resolve(llmRoot, 'index.md'), renderRootIndex(manualEntries, apiEntries, exampleEntries)); +await writeFile(resolve(llmRoot, 'README.md'), renderReadme()); +await writeFile(llmsTxtPath, renderLlmsTxt(manualEntries, apiEntries, exampleEntries)); + +console.log( + `[llm-docs] Wrote markdown corpus and /llms.txt: ` + + `${manualEntries.length} manual, ${apiEntries.length} api, ${exampleEntries.length} example files.`, +); + +// ---------- Manual ---------- + +async function emitManual() { + const sourceDir = resolve(manualSourceRoot, LOCALE); + + if (!existsSync(sourceDir)) { + return []; + } + + const files = (await readdir(sourceDir, { withFileTypes: true })) + .filter(entry => entry.isFile() && extname(entry.name) === '.md') + .map(entry => entry.name); + + const entries = []; + + for (const fileName of files) { + const filePath = resolve(sourceDir, fileName); + const source = await readFile(filePath, 'utf8'); + const { frontmatter, body } = parseFrontmatter(source); + const slug = fileName.replace(/\.md$/, ''); + const title = stringField(frontmatter, 'title') ?? slug; + const description = stringField(frontmatter, 'description') ?? ''; + const order = numberField(frontmatter, 'order') ?? 0; + const rewrittenBody = rewriteManualBody(body); + const markdown = renderManualMarkdown(title, description, rewrittenBody); + + await writeMarkdown(resolve(llmRoot, 'manual', `${slug}.md`), markdown); + entries.push({ slug, title, description, order }); + } + + entries.sort((left, right) => left.order - right.order || left.slug.localeCompare(right.slug)); + + await writeMarkdown(resolve(llmRoot, 'manual', 'index.md'), renderManualIndex(entries)); + + return entries; +} + +function renderManualMarkdown(title, description, body) { + const sections = [`# ${title}`]; + + if (description && description !== title) { + sections.push(`> ${description}`); + } + + sections.push(body.trim()); + + return `${sections.join('\n\n')}\n`; +} + +function renderManualIndex(entries) { + const lines = [ + '# Manual', + '', + '> Full product manual. Read `getting-started.md` then `basic-concepts.md` before using the SDK.', + '', + ]; + + for (const entry of entries) { + const description = entry.description && entry.description !== entry.title ? ` — ${entry.description}` : ''; + lines.push(`- [${entry.title}](./${entry.slug}.md)${description}`); + } + + return `${lines.join('\n')}\n`; +} + +function rewriteManualBody(body) { + const withRewrittenLinks = body.replace( + /(^|[^!])(\[[^\]]*]\()([^)\s]+)([^)]*\))/g, + (match, prefix, opening, href, closing) => { + const rewritten = rewriteManualHref(href); + return rewritten ? `${prefix}${opening}${rewritten}${closing}` : match; + }, + ); + + return withRewrittenLinks.replace(/(!\[[^\]]*]\()([^)\s]+)([^)]*\))/g, (match, opening, href, closing) => { + if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('/')) { + return match; + } + + const sourceLocation = `../../src/content/manual/${LOCALE}/${href.replace(/^\.?\/+/, '')}`; + return `${opening}${sourceLocation}${closing}`; + }); +} + +function rewriteManualHref(href) { + if (!href) { + return undefined; + } + + if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('#')) { + return undefined; + } + + if (href.startsWith('api:')) { + const symbol = href + .slice('api:'.length) + .replace(/[#?].*$/, '') + .trim(); + const entry = findApiEntryBySymbol(symbol); + return entry ? `../api/${entry.slug}.md` : undefined; + } + + if (href.startsWith('/')) { + return undefined; + } + + // Rewrite cross-section relative links like `../../examples/foo/` → `../examples/foo.md`. + const crossSection = href.match(/^\.\.\/\.\.\/(examples|api|manual)\/([^?#]*?)\/?([?#].*)?$/); + + if (crossSection) { + const [, section, slug, trailing = ''] = crossSection; + const cleanSlug = slug.replace(/\/$/, ''); + return `../${section}/${cleanSlug === '' ? 'index' : cleanSlug}.md${trailing}`; + } + + if (href.endsWith('.md') || /\.md[?#]/.test(href)) { + return href; + } + + return undefined; +} + +// ---------- API ---------- + +async function emitApi() { + if (!apiManifest) { + return []; + } + + const apiLocaleDir = resolve(generatedApiRoot, LOCALE); + + if (!existsSync(apiLocaleDir)) { + return []; + } + + const entries = []; + + for (const entry of apiManifest.entries ?? []) { + const sourcePath = resolve(apiLocaleDir, `${entry.slug}.html`); + + if (!existsSync(sourcePath)) { + continue; + } + + const html = await readFile(sourcePath, 'utf8'); + const body = htmlToMarkdown(html, { + rewriteLink: href => rewriteApiHref(href, entry.slug), + }); + const heading = entry.signature + ? `# ${entry.title}\n\n\`\`\`ts\n${entry.signature}\n\`\`\`` + : `# ${entry.title}`; + const meta = renderApiMeta(entry); + const description = entry.description ? `> ${entry.description}\n\n` : ''; + const markdown = `${heading}\n\n${meta}${description}${body.trim()}\n`; + + await writeMarkdown(resolve(llmRoot, 'api', `${entry.slug}.md`), markdown); + entries.push(entry); + } + + await writeMarkdown(resolve(llmRoot, 'api', 'index.md'), renderApiIndex(entries)); + + return entries; +} + +function renderApiMeta(entry) { + const segments = []; + + if (entry.categoryLabel) { + segments.push(`Category: \`${entry.categoryLabel}\``); + } + + if (entry.namespaceLabel && entry.namespaceLabel !== entry.categoryLabel) { + segments.push(`Namespace: \`${entry.namespaceLabel}\``); + } + + if (entry.kindLabel) { + segments.push(`Kind: \`${entry.kindLabel}\``); + } + + return segments.length ? `${segments.join(' · ')}\n\n` : ''; +} + +function renderApiIndex(entries) { + const grouped = new Map(); + + for (const entry of entries) { + const key = `${entry.categoryLabel ?? 'Misc'}::${entry.namespaceLabel ?? entry.categoryLabel ?? 'Misc'}`; + + if (!grouped.has(key)) { + grouped.set(key, { + categoryLabel: entry.categoryLabel ?? 'Misc', + namespaceLabel: entry.namespaceLabel ?? entry.categoryLabel ?? 'Misc', + entries: [], + }); + } + + grouped.get(key).entries.push(entry); + } + + const lines = [ + '# API Reference', + '', + '> Generated from TypeDoc, grouped by namespace. Each `.md` file corresponds to one exported symbol.', + '', + ]; + + let lastCategory; + + for (const group of grouped.values()) { + if (group.categoryLabel !== lastCategory) { + lines.push('', `## ${group.categoryLabel}`, ''); + lastCategory = group.categoryLabel; + } + + if (group.namespaceLabel && group.namespaceLabel !== group.categoryLabel) { + lines.push(`### ${group.namespaceLabel}`, ''); + } + + for (const entry of group.entries) { + lines.push(`- [${entry.title}](./${entry.slug}.md) — ${entry.kindLabel}`); + } + } + + return `${lines.join('\n')}\n`; +} + +function rewriteApiHref(href, currentSlug) { + if (!href) { + return undefined; + } + + const localePrefix = `/${LOCALE}/api/`; + + if (href.startsWith(localePrefix)) { + const [pathname, hash = ''] = splitHash(href.slice(localePrefix.length)); + const targetSlug = pathname.replace(/\/$/, ''); + if (!targetSlug) { + return `./index.md${hash}`; + } + return relativeMdHref(currentSlug, targetSlug) + hash; + } + + if (href.startsWith('http://') || href.startsWith('https://')) { + return href; + } + + return undefined; +} + +function splitHash(value) { + const index = value.indexOf('#'); + if (index === -1) { + return [value, '']; + } + return [value.slice(0, index), value.slice(index)]; +} + +function relativeMdHref(fromSlug, toSlug) { + const fromParts = fromSlug.split('/'); + const toParts = toSlug.split('/'); + fromParts.pop(); + + let common = 0; + while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) { + common += 1; + } + + const ups = fromParts.length - common; + const downs = toParts.slice(common); + const prefix = ups === 0 ? './' : '../'.repeat(ups); + + return `${prefix}${downs.join('/')}.md`; +} + +function findApiEntryBySymbol(symbol) { + if (!apiManifest) { + return undefined; + } + + const entries = apiManifest.entries ?? []; + const direct = entries.find(entry => entry.title === symbol); + + if (direct) { + return direct; + } + + const dotted = symbol.split('.'); + + if (dotted.length === 2) { + return entries.find(entry => entry.namespace === dotted[0] && entry.title === dotted[1]); + } + + return undefined; +} + +async function loadApiManifest() { + const manifestPath = resolve(generatedApiRoot, 'manifest.ts'); + + if (!existsSync(manifestPath)) { + return undefined; + } + + const source = await readFile(manifestPath, 'utf8'); + const start = source.indexOf('{'); + const end = source.lastIndexOf('}'); + + if (start === -1 || end === -1 || end <= start) { + return undefined; + } + + return JSON.parse(source.slice(start, end + 1)); +} + +// ---------- Examples ---------- + +async function emitExamples() { + if (!existsSync(examplesSourceRoot)) { + return []; + } + + const entries = await readdir(examplesSourceRoot, { withFileTypes: true }); + const slugs = entries + .filter(entry => entry.isFile() && extname(entry.name) === '.json') + .map(entry => entry.name.replace(/\.json$/, '')); + + const items = []; + + for (const slug of slugs) { + const jsonPath = resolve(examplesSourceRoot, `${slug}.json`); + const codePath = resolve(examplesSourceRoot, `${slug}.ts`); + const meta = JSON.parse(await readFile(jsonPath, 'utf8')); + const code = existsSync(codePath) ? await readFile(codePath, 'utf8') : ''; + const title = meta?.title?.[LOCALE] ?? meta?.title?.['en-US'] ?? slug; + const tags = Array.isArray(meta?.tags) ? meta.tags : []; + const order = typeof meta?.order === 'number' ? meta.order : Number.POSITIVE_INFINITY; + + const markdown = renderExampleMarkdown({ slug, title, tags, code }); + + await writeMarkdown(resolve(llmRoot, 'examples', `${slug}.md`), markdown); + items.push({ slug, title, tags, order }); + } + + items.sort((left, right) => left.order - right.order || left.slug.localeCompare(right.slug)); + + await writeMarkdown(resolve(llmRoot, 'examples', 'index.md'), renderExamplesIndex(items)); + + return items; +} + +function renderExampleMarkdown({ slug, title, tags, code }) { + const tagLine = tags.length ? `Tags: ${tags.map(tag => `\`${tag}\``).join(', ')}\n\n` : ''; + const sourceHint = `Source: \`website/src/content/examples/${slug}.ts\`\n\n`; + const liveUrl = `/${LOCALE}/examples/${slug}/`; + const codeBlock = code.trim() ? `\`\`\`ts\n${code.trim()}\n\`\`\`\n` : '_No code attached._\n'; + + return `# ${title}\n\n${tagLine}${sourceHint}Live page: \`${liveUrl}\`\n\n${codeBlock}`; +} + +function renderExamplesIndex(items) { + const lines = [ + '# Examples', + '', + '> Each example demonstrates one SDK capability. The markdown file contains the original TypeScript source.', + '', + ]; + + for (const item of items) { + const tagSuffix = item.tags.length ? ` — tags: ${item.tags.join(', ')}` : ''; + lines.push(`- [${item.title}](./${item.slug}.md)${tagSuffix}`); + } + + return `${lines.join('\n')}\n`; +} + +// ---------- Root index ---------- + +function renderRootIndex(manual, api, examples) { + const lines = [ + '# Aholo Viewer — AI-Friendly Documentation', + '', + 'This directory is the machine-readable corpus of the Aholo Viewer docs. ', + 'It is regenerated on every `pnpm build` from the same sources that produce the public website.', + '', + '## Entry Points', + '', + `- [Manual](./manual/index.md) — ${manual.length} pages`, + `- [API Reference](./api/index.md) — ${api.length} symbols`, + `- [Examples](./examples/index.md) — ${examples.length} runnable demos`, + '', + '## Recommended Reading Order', + '', + '1. `manual/getting-started.md` — install and bootstrap a viewer in minutes.', + '2. `manual/basic-concepts.md` — mental model for scene, camera, renderer.', + '3. Browse `api/` and `examples/` on demand.', + '', + '## Directory Layout', + '', + '```', + 'llm/', + ' index.md # this file', + ' manual/ # user manual (markdown)', + ' api/ # API reference (converted from TypeDoc HTML)', + ' examples/ # runnable examples with TypeScript source', + '```', + '', + ]; + + return `${lines.join('\n')}\n`; +} + +function renderReadme() { + return [ + '# /llm — AI-friendly docs', + '', + 'This folder is generated by `scripts/generate-llm-docs.mjs` as part of `pnpm build`.', + '', + 'It mirrors the human-facing site under `/{locale}/manual`, `/{locale}/api`, `/{locale}/examples`', + 'but as plain English markdown, which is easier for automated coding agents to parse.', + '', + 'Do not edit files here directly — they will be regenerated on every build.', + '', + ].join('\n'); +} + +function renderLlmsTxt(manual, api, examples) { + return [ + '# Aholo Viewer', + '', + '> High-performance 3D Gaussian Splatting rendering for web applications.', + '', + 'AI assistants should use the markdown corpus under `/llm/` before scraping the human-facing site.', + '', + '## Primary Docs', + '', + `- [AI documentation index](/llm/index.md): overview and recommended reading order.`, + `- [Manual](/llm/manual/index.md): product manual, ${manual.length} pages.`, + `- [API Reference](/llm/api/index.md): exported SDK symbols, ${api.length} entries.`, + `- [Examples](/llm/examples/index.md): runnable TypeScript examples, ${examples.length} demos.`, + '', + '## Recommended Use', + '', + '- Start with `/llm/manual/getting-started.md` for setup.', + '- Read `/llm/manual/basic-concepts.md` for the scene, camera, and renderer model.', + '- Use `/llm/api/index.md` to locate public exports.', + '- Use `/llm/examples/index.md` for runnable integration patterns.', + '', + ].join('\n'); +} + +// ---------- Utilities ---------- + +async function writeMarkdown(filePath, content) { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, content); +} + +function parseFrontmatter(source) { + if (!source.startsWith('---')) { + return { frontmatter: {}, body: source }; + } + + const closing = source.indexOf('\n---', 3); + + if (closing === -1) { + return { frontmatter: {}, body: source }; + } + + const rawFrontmatter = source.slice(3, closing).trim(); + const body = source.slice(closing + 4).replace(/^\r?\n/, ''); + const frontmatter = {}; + + for (const line of rawFrontmatter.split(/\r?\n/)) { + const match = line.match(/^([\w-]+)\s*:\s*(.*)$/); + + if (!match) { + continue; + } + + const [, key, rawValue] = match; + const trimmed = rawValue.trim().replace(/^['"](.*)['"]$/, '$1'); + + frontmatter[key] = trimmed; + } + + return { frontmatter, body }; +} + +function stringField(frontmatter, key) { + const value = frontmatter[key]; + + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + + return trimmed === '' ? undefined : trimmed; +} + +function numberField(frontmatter, key) { + const value = Number(frontmatter[key]); + + return Number.isFinite(value) ? value : undefined; +} + +// ---------- HTML → Markdown ---------- + +function htmlToMarkdown(html, { rewriteLink } = {}) { + let working = html; + + working = working.replace(/]*class=(["'])[^"']*\btsd-anchor-icon\b[^"']*\1[^>]*>[\s\S]*?<\/a>/g, ''); + working = working.replace(//g, ''); + working = working.replace(//g, ''); + + working = working.replace(/]*>\s*]*)>([\s\S]*?)<\/code>\s*<\/pre>/g, (_match, attrs, inner) => { + const langMatch = attrs.match(/class=(["'])([^"']*)\1/); + const langClass = langMatch?.[2] ?? ''; + const lang = langClass.match(/language-([\w-]+)/)?.[1] ?? ''; + const text = decodeHtmlEntities(stripTags(inner)); + return `\n\n\`\`\`${lang}\n${text.replace(/\n+$/, '')}\n\`\`\`\n\n`; + }); + + working = working.replace(/]*>([\s\S]*?)<\/pre>/g, (_match, inner) => { + const text = decodeHtmlEntities(stripTags(inner)); + return `\n\n\`\`\`\n${text.replace(/\n+$/, '')}\n\`\`\`\n\n`; + }); + + working = working.replace(/]*>([\s\S]*?)<\/code>/g, (_match, inner) => { + const text = decodeHtmlEntities(stripTags(inner)).replace(/`/g, '\\`'); + return `\`${text}\``; + }); + + working = working.replace(/]*>([\s\S]*?)<\/h\1>/g, (_match, level, inner) => { + const text = decodeHtmlEntities(stripTags(inner)).trim(); + if (!text) return ''; + return `\n\n${'#'.repeat(Number(level))} ${text}\n\n`; + }); + + working = working.replace(/]*)>([\s\S]*?)<\/a>/g, (_match, attrs, inner) => { + const hrefMatch = attrs.match(/href=(["'])([^"']*)\1/); + const text = decodeHtmlEntities(stripTags(inner)).trim(); + + if (!hrefMatch) { + return text; + } + + const rawHref = decodeHtmlEntities(hrefMatch[2]); + const finalHref = rewriteLink ? (rewriteLink(rawHref) ?? rawHref) : rawHref; + + if (!text) { + return finalHref; + } + + return `[${text}](${finalHref})`; + }); + + working = working.replace(/<(?:strong|b)\b[^>]*>([\s\S]*?)<\/(?:strong|b)>/g, (_match, inner) => { + return `**${decodeHtmlEntities(stripTags(inner)).trim()}**`; + }); + working = working.replace(/<(?:em|i)\b[^>]*>([\s\S]*?)<\/(?:em|i)>/g, (_match, inner) => { + return `*${decodeHtmlEntities(stripTags(inner)).trim()}*`; + }); + + working = working.replace(/(\s*)/g, '\n$1'); + + working = working.replace(/<(ul|ol)\b[^>]*>([\s\S]*?)<\/\1>/g, (_match, tag, inner) => { + const isOrdered = tag === 'ol'; + const items = []; + let counter = 1; + + inner.replace(/]*>([\s\S]*?)<\/li>/g, (_full, itemInner) => { + const itemText = collapseWhitespace(decodeHtmlEntities(stripTags(itemInner))); + if (itemText) { + const bullet = isOrdered ? `${counter}.` : '-'; + items.push(`${bullet} ${itemText}`); + counter += 1; + } + return ''; + }); + + return `\n\n${items.join('\n')}\n\n`; + }); + + working = working.replace(/<(p|section|article|div|details|summary)\b[^>]*>/g, '\n\n'); + working = working.replace(/<\/(p|section|article|div|details|summary)>/g, '\n\n'); + + working = stripTags(working); + working = decodeHtmlEntities(working); + working = working.replace(/[ \t]+\n/g, '\n'); + working = working.replace(/\n{3,}/g, '\n\n'); + + return working.trim(); +} + +function stripTags(value) { + return value.replace(/<[^>]+>/g, ''); +} + +function collapseWhitespace(value) { + return value.replace(/\s+/g, ' ').trim(); +} + +function decodeHtmlEntities(value) { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number(code))) + .replace(/&#x([0-9a-fA-F]+);/g, (_match, code) => String.fromCodePoint(parseInt(code, 16))) + .replace(/&/g, '&'); +} diff --git a/scripts/package-utils.mjs b/scripts/package-utils.mjs new file mode 100644 index 0000000..6b4b841 --- /dev/null +++ b/scripts/package-utils.mjs @@ -0,0 +1,156 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +export function resolvePackageRoot(packageRootArg = process.argv[2]) { + const packageRoot = resolve(process.cwd(), packageRootArg ?? '.'); + assertInsideDir(workspaceRoot, packageRoot, 'Package root'); + + const packageJsonPath = resolve(packageRoot, 'package.json'); + + if (!existsSync(packageJsonPath)) { + throw new Error(`Package root is missing package.json: ${packageRoot}`); + } + + return packageRoot; +} + +export function readPackageJson(packageRoot) { + return readJsonFile(resolve(packageRoot, 'package.json'), 'package metadata'); +} + +export function readJsonFile(filePath, label = 'JSON') { + try { + return JSON.parse(readFileSync(filePath, 'utf8')); + } catch (error) { + throw new Error(`Unable to read ${label} at ${filePath}.`, { cause: error }); + } +} + +export function resolvePackagePath(packageRoot, value, label) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Missing ${label}.`); + } + + const absolutePath = resolve(packageRoot, value); + assertInsideDir(packageRoot, absolutePath, label); + return absolutePath; +} + +export function resolveWorkspacePath(value, label) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Missing ${label}.`); + } + + const absolutePath = resolve(workspaceRoot, value); + assertInsideDir(workspaceRoot, absolutePath, label); + return absolutePath; +} + +export function assertInsideDir(parent, child, label) { + if (!isInsideDir(parent, child)) { + throw new Error(`${label} must stay inside ${parent}: ${child}`); + } +} + +export function isInsideDir(parent, child) { + const relativePath = relative(resolve(parent), resolve(child)); + + return relativePath === '' || (!!relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)); +} + +export function formatWorkspacePath(filePath) { + return toPosixPath(relative(workspaceRoot, resolve(filePath))); +} + +export function statSafe(filePath) { + try { + return statSync(filePath); + } catch { + return undefined; + } +} + +export function listFiles(directory, options = {}) { + if (!existsSync(directory)) { + return []; + } + + const files = []; + const skipDirectories = new Set(options.skipDirectories ?? []); + + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory() && skipDirectories.has(entry.name)) { + continue; + } + + const entryPath = resolve(directory, entry.name); + + if (entry.isDirectory()) { + files.push(...listFiles(entryPath, options)); + continue; + } + + if (entry.isFile()) { + files.push(entryPath); + } + } + + return files; +} + +export function readWebsiteLocales() { + const localeSourcePath = resolve(workspaceRoot, 'website/src/i18n/locales.ts'); + const source = readFileSync(localeSourcePath, 'utf8'); + const localeMatches = [...source.matchAll(/code:\s*["']([^"']+)["']/g)].map(match => match[1]); + + if (localeMatches.length === 0) { + throw new Error(`Unable to read website locales from ${formatWorkspacePath(localeSourcePath)}.`); + } + + return localeMatches; +} + +export function getPnpmCommand() { + return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +} + +export function runCommand(command, args, options = {}) { + return spawnSync(command, args, { + cwd: options.cwd ?? workspaceRoot, + encoding: options.encoding, + env: options.env ?? process.env, + shell: options.shell ?? false, + stdio: options.stdio ?? 'inherit', + }); +} + +export function runCommandOrExit(command, args, options = {}) { + const result = runCommand(command, args, options); + + if (result.status === 0) { + return result; + } + + const cwd = options.cwd ?? workspaceRoot; + const label = options.label ?? `${command} ${args.join(' ')}`; + + console.error(`[scripts] Command failed in ${cwd}: ${label}`); + + if (result.error) { + console.error(result.error); + } + + if (result.stderr) { + console.error(result.stderr); + } + + process.exit(result.status ?? 1); +} + +export function toPosixPath(value) { + return value.replace(/\\/g, '/'); +} diff --git a/scripts/prepare-egs-types.mjs b/scripts/prepare-egs-types.mjs new file mode 100644 index 0000000..0c960d4 --- /dev/null +++ b/scripts/prepare-egs-types.mjs @@ -0,0 +1,43 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { getPnpmCommand, runCommandOrExit, workspaceRoot } from './package-utils.mjs'; + +const rootDir = workspaceRoot; +const egsRoot = join(rootDir, 'external/egs-core'); + +if (process.env.AHOLO_SKIP_EGS_TYPES === '1') { + console.log('[egs-types] Skipping EGS type preparation because AHOLO_SKIP_EGS_TYPES=1.'); + process.exit(0); +} + +runCommandOrExit(process.execPath, [join(rootDir, 'scripts/ensure-submodules.mjs')], { + cwd: rootDir, + label: 'node scripts/ensure-submodules.mjs', +}); +ensureEgsInstall(); + +runCommandOrExit(getPnpmCommand(), ['run', '--filter=!@internal/*', '-r', '--if-present', 'build:types:release'], { + cwd: egsRoot, + label: 'pnpm run --filter=!@internal/* -r --if-present build:types:release', + shell: process.platform === 'win32', +}); + +console.log('[egs-types] Ready.'); + +function ensureEgsInstall() { + const requiredFiles = [ + join(egsRoot, 'node_modules/@internal/tsconfig/index.json'), + join(egsRoot, 'node_modules/typescript/lib/tsc.js'), + ]; + + if (requiredFiles.every(file => existsSync(file))) { + return; + } + + console.log('[egs-types] Installing EGS build dependencies from external/egs-core/pnpm-lock.yaml.'); + runCommandOrExit(getPnpmCommand(), ['install', '--frozen-lockfile', '--ignore-scripts'], { + cwd: egsRoot, + label: 'pnpm install --frozen-lockfile --ignore-scripts', + shell: process.platform === 'win32', + }); +} diff --git a/scripts/publish-packages.mjs b/scripts/publish-packages.mjs new file mode 100644 index 0000000..be71959 --- /dev/null +++ b/scripts/publish-packages.mjs @@ -0,0 +1,74 @@ +import child_process from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const packages = JSON.parse( + child_process.execSync('pnpm list --filter=@manycore/* -r -depth -1 --json', { stdio: 'pipe' }).toString('utf-8'), +).filter(item => !item.private); + +// update splat-transform sub packages version. +{ + const splatTransformSubPackages = packages.filter( + item => + item.name.startsWith('@manycore/aholo-splat-transform') && item.name !== '@manycore/aholo-splat-transform', + ); + const splatTransformMainPackage = packages.find(item => item.name === '@manycore/aholo-splat-transform'); + const splatTransformMainPackageJson = JSON.parse( + fs.readFileSync(path.resolve(splatTransformMainPackage.path, 'package.json'), 'utf-8'), + ); + for (const p of splatTransformSubPackages) { + const packageJson = JSON.parse(fs.readFileSync(path.resolve(p.path, 'package.json'), 'utf-8')); + p.version = splatTransformMainPackageJson.version; + packageJson.version = splatTransformMainPackageJson.version; + splatTransformMainPackageJson.optionalDependencies[p.name] = splatTransformMainPackageJson.version; + fs.writeFileSync(path.resolve(p.path, 'package.json'), JSON.stringify(packageJson, undefined, 2), 'utf-8'); + } + fs.writeFileSync( + path.resolve(splatTransformMainPackage.path, 'package.json'), + JSON.stringify(splatTransformMainPackageJson, undefined, 2), + 'utf-8', + ); +} + +const publishedPackages = []; + +for (const p of packages) { + const cwd = p.path; + const packageJson = JSON.parse(fs.readFileSync(path.resolve(cwd, 'package.json'), 'utf-8')); + const hiddenBuildCommand = packageJson.scripts?.['.build']; + let published = false; + try { + child_process.execSync(`npm view ${p.name}@${p.version}`, { stdio: 'ignore' }); + published = true; + } catch { + // assume not found. should publish. + } + if (!published) { + if (hiddenBuildCommand) { + // hidden build command exists, add build commands to call .build + packageJson.scripts.build = 'pnpm run .build'; + fs.writeFileSync(path.resolve(cwd, 'package.json'), JSON.stringify(packageJson, undefined, 2), 'utf-8'); + } + // run build command if exists + child_process.execSync('pnpm run --if-present build', { stdio: 'inherit', cwd }); + // cleanup package.json before publish + child_process.execSync('npm pkg delete scripts devDependencies', { stdio: 'inherit', cwd }); + child_process.execSync('npm publish --access public', { stdio: 'inherit', cwd }); + // restore package.json + if (hiddenBuildCommand) { + delete packageJson.scripts.build; + } + fs.writeFileSync(path.resolve(cwd, 'package.json'), JSON.stringify(packageJson, undefined, 2), 'utf-8'); + publishedPackages.push({ + name: p.name, + version: p.version, + }); + } +} + +if (publishedPackages.length > 0) { + console.log('published:'); + for (const p of publishedPackages) { + console.log(`\t${p.name}@${p.version}`); + } +} diff --git a/scripts/read-package-changelog.mjs b/scripts/read-package-changelog.mjs new file mode 100644 index 0000000..e7e947d --- /dev/null +++ b/scripts/read-package-changelog.mjs @@ -0,0 +1,60 @@ +import child_process from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { assertInsideDir, formatWorkspacePath, readPackageJson, resolveWorkspacePath } from './package-utils.mjs'; + +const [packageName, versionArg] = process.argv.slice(2); + +if (!packageName) { + console.error('Usage: node scripts/read-package-changelog.mjs [version]'); + process.exit(1); +} + +const packageInfo = JSON.parse( + child_process.execSync('pnpm list --filter=@manycore/* -r -depth -1 --json', { stdio: 'pipe' }).toString('utf-8'), +).find(item => item.name === packageName); + +const packageRoot = resolveWorkspacePath(packageInfo.path, 'Package root'); +const packageJson = readPackageJson(packageRoot); +const packageVersion = versionArg ?? packageJson.version; + +if (typeof packageVersion !== 'string' || packageVersion.trim() === '') { + throw new Error(`Missing package version for ${formatWorkspacePath(packageRoot)}.`); +} + +const changelogPath = resolve(packageRoot, 'CHANGELOG.md'); +assertInsideDir(packageRoot, changelogPath, 'Changelog path'); + +if (!existsSync(changelogPath)) { + throw new Error(`Missing CHANGELOG.md for ${formatWorkspacePath(packageRoot)}.`); +} + +const changelog = readFileSync(changelogPath, 'utf8'); +const body = readChangelogSection(changelog, packageVersion, formatWorkspacePath(changelogPath)); + +process.stdout.write(body); + +function readChangelogSection(changelog, version, changelogLabel) { + const versionHeading = new RegExp(`^##\\s+\\[?${escapeRegExp(version)}\\]?(?:\\s.*)?$`, 'm'); + const match = versionHeading.exec(changelog); + + if (!match) { + throw new Error(`Missing ${version} section in ${changelogLabel}.`); + } + + const sectionStart = match.index + match[0].length; + const rest = changelog.slice(sectionStart); + const nextSection = /^##\s+/m.exec(rest); + const body = (nextSection ? rest.slice(0, nextSection.index) : rest).trim(); + + if (!body) { + throw new Error(`Empty ${version} section in ${changelogLabel}.`); + } + + return body; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..a8cba2f --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,10 @@ +{ + "extends": "@internal/utils/tsconfig.json", + "compilerOptions": { + "resolveJsonModule": true, + "noEmit": true, + "allowJs": true, + "jsx": "preserve", + "strict": true + } +} diff --git a/website/astro.config.mjs b/website/astro.config.mjs new file mode 100644 index 0000000..593eec5 --- /dev/null +++ b/website/astro.config.mjs @@ -0,0 +1,473 @@ +import { defineConfig } from 'astro/config'; +import { spawn } from 'node:child_process'; +import { createReadStream, existsSync } from 'node:fs'; +import { copyFile, mkdir, readdir, rm, stat } from 'node:fs/promises'; +import { dirname, extname, isAbsolute, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import checker from 'vite-plugin-checker'; +import { + assetsPrefix, + astroAssetsDir, + buildHashLength, + getManualAssetOutputPath, + manualAssetsDir, + manualAssetPath, +} from './src/utils/manual-assets.js'; + +const workspaceRoot = fileURLToPath(new URL('../', import.meta.url)); +const manualContentRoot = fileURLToPath(new URL('./src/content/manual', import.meta.url)); +const manualAssetSourceRoot = fileURLToPath(new URL('./src/content/manual/assets', import.meta.url)); +const manualBuildAssetRoot = fileURLToPath(new URL(`./dist/${astroAssetsDir}/${manualAssetsDir}`, import.meta.url)); +const manualDevAssetPath = manualAssetPath; +const examplesContentRoot = fileURLToPath(new URL('./src/content/examples', import.meta.url)); +const rolldownOutputFileNames = { + entryFileNames: `${astroAssetsDir}/[name].[hash:${buildHashLength}].js`, + chunkFileNames: `${astroAssetsDir}/[name].[hash:${buildHashLength}].js`, + assetFileNames: `${astroAssetsDir}/[name].[hash:${buildHashLength}][extname]`, +}; +const egsCoreWatchRoots = [ + fileURLToPath(new URL('../external/egs-core/packages', import.meta.url)), + fileURLToPath(new URL('../external/egs-core/tools/utils', import.meta.url)), +]; +const contentReloadEvents = ['add', 'change', 'unlink']; +const manualInvalidationRules = { + includes: ['/src/content/manual/', '/src/pages/[lang]/manual/'], + endsWith: ['/src/utils/manual.ts'], +}; +const examplesInvalidationRules = { + includes: ['/src/content/examples/', '/src/pages/[lang]/examples/'], + endsWith: [ + '/src/components/PlaygroundShell.astro', + '/src/pages/[lang]/index.astro', + '/src/pages/[lang]/playground.astro', + '/src/utils/examples.ts', + ], +}; +const rendererInvalidationRules = { + includes: ['/packages/renderer/dist/', '/node_modules/@manycore/aholo-viewer/', '@manycore/aholo-viewer'], + endsWith: ['/packages/renderer/dist/index.js', '/packages/renderer/dist/splat-worker.js'], +}; + +const isProd = process.env.NODE_ENV === 'production'; + +export default defineConfig({ + output: 'static', + trailingSlash: 'always', + build: { + assets: astroAssetsDir, + assetsPrefix, + }, + vite: { + build: { + rolldownOptions: { + output: rolldownOutputFileNames, + }, + }, + worker: { + rolldownOptions: { + output: rolldownOutputFileNames, + }, + }, + server: { + watch: { + interval: 250, + usePolling: true, + }, + }, + optimizeDeps: { + exclude: ['monaco-editor', '@manycore/aholo-viewer'], + }, + environments: { + client: { + build: { + rolldownOptions: { + output: rolldownOutputFileNames, + }, + }, + }, + }, + plugins: [ + !isProd && + checker({ + typescript: true, + }), + manualAssetsPlugin(), + manualContentReloadPlugin(), + examplesContentReloadPlugin(), + egsCoreReloadPlugin(), + ], + }, +}); + +function manualAssetsPlugin() { + return { + name: 'aholo-manual-assets', + configureServer(server) { + server.watcher.add(manualAssetSourceRoot); + server.middlewares.use(manualDevAssetPath, async (request, response, next) => { + try { + const requestedPath = getManualAssetRequestPath(request.url); + + if (!requestedPath) { + next(); + return; + } + + const assetPath = resolve(manualAssetSourceRoot, requestedPath); + + if (!isWithinRoot(manualAssetSourceRoot, assetPath)) { + next(); + return; + } + + const assetStat = await stat(assetPath); + + if (!assetStat.isFile()) { + next(); + return; + } + + response.statusCode = 200; + response.setHeader('Content-Type', getContentType(assetPath)); + createReadStream(assetPath).pipe(response); + } catch { + next(); + } + }); + }, + async closeBundle() { + if (!existsSync(manualAssetSourceRoot)) { + return; + } + + await rm(manualBuildAssetRoot, { recursive: true, force: true }); + await copyManualBuildAssets(manualAssetSourceRoot); + }, + }; +} + +async function copyManualBuildAssets(root, currentDir = root) { + const entries = await readdir(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const sourcePath = resolve(currentDir, entry.name); + + if (entry.isDirectory()) { + await copyManualBuildAssets(root, sourcePath); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const relativeAssetPath = toPosixPath(relative(root, sourcePath)); + const outputAssetPath = getManualAssetOutputPath(relativeAssetPath, sourcePath, { hash: true }); + const buildAssetPath = resolve(manualBuildAssetRoot, outputAssetPath); + + await mkdir(dirname(buildAssetPath), { recursive: true }); + await copyFile(sourcePath, buildAssetPath); + } +} + +function manualContentReloadPlugin() { + return contentReloadPlugin({ + name: 'aholo-manual-content-reload', + root: manualContentRoot, + shouldReload: isManualMarkdown, + invalidate: invalidateManualModules, + notify: [sendFullReload], + }); +} + +function examplesContentReloadPlugin() { + return contentReloadPlugin({ + name: 'aholo-examples-content-reload', + root: examplesContentRoot, + shouldReload: isExampleContent, + invalidate: invalidateExampleModules, + notify: [sendContentChanged, sendFullReload], + }); +} + +function egsCoreReloadPlugin() { + let rebuildTimer; + let rebuildRunning = false; + let rebuildQueued = false; + + return { + name: 'aholo-egs-core-reload', + apply: 'serve', + configureServer(server) { + server.watcher.setMaxListeners(Math.max(server.watcher.getMaxListeners(), 20)); + server.watcher.add(egsCoreWatchRoots.filter(root => existsSync(root))); + + const scheduleRebuild = file => { + if (!isEgsCoreDevSource(file)) { + return; + } + + clearTimeout(rebuildTimer); + rebuildTimer = setTimeout(() => { + rebuildTimer = undefined; + void rebuildRendererFromEgs(server); + }, 250); + }; + + for (const eventName of contentReloadEvents) { + server.watcher.on(eventName, scheduleRebuild); + } + }, + }; + + async function rebuildRendererFromEgs(server) { + if (rebuildRunning) { + rebuildQueued = true; + return; + } + + rebuildRunning = true; + + try { + do { + rebuildQueued = false; + console.log('[egs-dev] EGS source changed. Rebuilding renderer.'); + await runWorkspaceScript('.renderer:build'); + } while (rebuildQueued); + + invalidateRendererModules(server); + sendFullReload(server); + console.log('[egs-dev] Renderer rebuild complete. Site reloaded.'); + } catch (error) { + console.error('[egs-dev] Renderer rebuild failed. Fix the error and save again to retry.'); + console.error(error); + } finally { + rebuildRunning = false; + + if (rebuildQueued) { + void rebuildRendererFromEgs(server); + } + } + } +} + +function contentReloadPlugin({ name, root, shouldReload, invalidate, notify }) { + return { + name, + configureServer(server) { + server.watcher.setMaxListeners(Math.max(server.watcher.getMaxListeners(), 20)); + server.watcher.add(root); + + const reload = file => { + if (!shouldReload(file)) { + return; + } + + invalidate(server); + + for (const send of notify) { + send(server); + } + }; + + for (const eventName of contentReloadEvents) { + server.watcher.on(eventName, reload); + } + }, + }; +} + +function invalidateManualModules(server) { + invalidateModules(server, id => matchesRule(id, manualInvalidationRules)); +} + +function invalidateExampleModules(server) { + invalidateModules(server, id => matchesRule(id, examplesInvalidationRules)); +} + +function invalidateRendererModules(server) { + invalidateModules(server, id => matchesRule(id, rendererInvalidationRules)); +} + +function invalidateModules(server, shouldInvalidate) { + const invalidatedModules = new Set(); + const timestamp = Date.now(); + + for (const moduleGraph of getModuleGraphs(server)) { + for (const moduleNode of getModuleNodes(moduleGraph)) { + const id = toPosixPath(moduleNode.id ?? moduleNode.url ?? ''); + + if (!id || !shouldInvalidate(id) || invalidatedModules.has(moduleNode)) { + continue; + } + + moduleGraph.invalidateModule(moduleNode, undefined, timestamp, true); + invalidatedModules.add(moduleNode); + } + } +} + +function getModuleGraphs(server) { + const moduleGraphs = []; + const environments = server.environments ? Object.values(server.environments) : []; + + for (const environment of environments) { + if (environment?.moduleGraph && !moduleGraphs.includes(environment.moduleGraph)) { + moduleGraphs.push(environment.moduleGraph); + } + } + + if (server.moduleGraph && !moduleGraphs.includes(server.moduleGraph)) { + moduleGraphs.push(server.moduleGraph); + } + + return moduleGraphs; +} + +function getModuleNodes(moduleGraph) { + if (moduleGraph.idToModuleMap) { + return moduleGraph.idToModuleMap.values(); + } + + if (moduleGraph.urlToModuleMap) { + return moduleGraph.urlToModuleMap.values(); + } + + return []; +} + +function matchesRule(value, rule) { + const matchesInclude = rule.includes.some(pattern => value.includes(pattern)); + const matchesSuffix = rule.endsWith.some(pattern => value.endsWith(pattern)); + + return matchesInclude || matchesSuffix; +} + +function sendContentChanged(server) { + server.environments?.ssr?.hot?.send('astro:content-changed', {}); +} + +function sendFullReload(server) { + const payload = { type: 'full-reload', path: '*' }; + + if (server.environments?.client?.hot) { + server.environments.client.hot.send(payload); + return; + } + + server.ws.send(payload); +} + +function isManualMarkdown(file) { + const normalized = toPosixPath(file); + + return normalized.includes('/src/content/manual/') && normalized.endsWith('.md'); +} + +function isExampleContent(file) { + const normalized = toPosixPath(file); + + return normalized.includes('/src/content/examples/') && ['.json', '.ts'].includes(extname(normalized)); +} + +function isEgsCoreDevSource(file) { + if (!file) { + return false; + } + + const normalized = toPosixPath(file); + + if ( + normalized.includes('/build/') || + normalized.includes('/dist/') || + normalized.includes('/node_modules/') || + !egsCoreWatchRoots.some(root => isWithinRoot(root, file)) + ) { + return false; + } + + return ( + normalized.endsWith('.ts') || + normalized.endsWith('.tsx') || + normalized.endsWith('/package.json') || + normalized.endsWith('/tsconfig.json') + ); +} + +function runWorkspaceScript(scriptName) { + return new Promise((resolveRun, rejectRun) => { + const child = spawn(getPnpmCommand(), ['run', scriptName], { + cwd: workspaceRoot, + shell: process.platform === 'win32', + stdio: 'inherit', + }); + let settled = false; + + const finish = error => { + if (settled) { + return; + } + + settled = true; + + if (error) { + rejectRun(error); + return; + } + + resolveRun(); + }; + + child.on('error', finish); + child.on('close', (code, signal) => { + if (code === 0) { + finish(); + return; + } + + finish(new Error(`pnpm run ${scriptName} failed with ${signal ?? `exit code ${code}`}.`)); + }); + }); +} + +function getPnpmCommand() { + return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +} + +function toPosixPath(value) { + return value.replace(/\\/g, '/'); +} + +function getManualAssetRequestPath(url) { + let pathname = new URL(url ?? '/', 'http://localhost').pathname; + + if (pathname.startsWith(`${manualDevAssetPath}/`)) { + pathname = pathname.slice(manualDevAssetPath.length); + } + + return decodeURIComponent(pathname).replace(/^\/+/, '') || undefined; +} + +function isWithinRoot(root, filePath) { + const relativePath = relative(root, filePath); + + return relativePath === '' || (!!relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)); +} + +function getContentType(filePath) { + switch (extname(filePath).toLowerCase()) { + case '.gif': + return 'image/gif'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.png': + return 'image/png'; + case '.svg': + return 'image/svg+xml'; + case '.webp': + return 'image/webp'; + default: + return 'application/octet-stream'; + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..4855cc9 --- /dev/null +++ b/website/package.json @@ -0,0 +1,31 @@ +{ + "name": "@manycore/aholo-viewer-website", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + ".dev": "astro dev --host", + ".build": "astro build", + ".preview": "astro preview --host 127.0.0.1", + ".check": "astro check", + ".clean": "node scripts/clean.mjs" + }, + "dependencies": { + "@astrojs/markdown-satteri": "^0.3.3", + "@manycore/aholo-viewer": "workspace:*", + "@tweakpane/plugin-essentials": "^0.2.1", + "astro": "^7.0.6", + "lz-string": "^1.5.0", + "monaco-editor": "^0.55.1", + "tslib": "^2.8.1", + "tweakpane": "^4.0.5", + "yaml": "^2.9.0" + }, + "devDependencies": { + "@astrojs/check": "^0.9.9", + "@tweakpane/core": "^2.0.5", + "@types/node": "^22.20.0", + "typescript": "^6.0.3", + "vite-plugin-checker": "^0.14.4" + } +} diff --git a/website/public/aholo-logo.svg b/website/public/aholo-logo.svg new file mode 100644 index 0000000..0d32236 --- /dev/null +++ b/website/public/aholo-logo.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/public/favicon.ico b/website/public/favicon.ico new file mode 100644 index 0000000..a59a255 Binary files /dev/null and b/website/public/favicon.ico differ diff --git a/website/public/home/feature-tools.svg b/website/public/home/feature-tools.svg new file mode 100644 index 0000000..9568f1b --- /dev/null +++ b/website/public/home/feature-tools.svg @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Input assets + + + + + PLY + + + + + + SPZ + + + + + + SOG + + + + + + LCC + + + + + + SPLAT + + + + + + + + + + + + + + + + + splat-transform pipeline.json + + { + "create" + : "normalize formats", + "lod:auto-chunk" + : "streaming", + "voxel" + : "collision SVO", + "output" + : "viewer-ready output" + } + + pnpm splat-transform + + + + + + + + Generated outputs + + + LOD chunks + budgeted streaming + + + + + + + + + Voxel SVO + ray and capsule tests + + + + + + + + + + + + + Edit + pick + runtime interaction + + + + + + diff --git a/website/public/icons/example-edit-code.svg b/website/public/icons/example-edit-code.svg new file mode 100644 index 0000000..47fccbb --- /dev/null +++ b/website/public/icons/example-edit-code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/website/scripts/clean.mjs b/website/scripts/clean.mjs new file mode 100644 index 0000000..8b36e00 --- /dev/null +++ b/website/scripts/clean.mjs @@ -0,0 +1,37 @@ +import { rm } from 'node:fs/promises'; +import { dirname, isAbsolute, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const cleanTargets = ['dist', '.astro', '.generated', 'src/content/api']; +const args = process.argv.slice(2); +const allowedArgs = new Set(['--dry-run']); +const unknownArgs = args.filter(arg => !allowedArgs.has(arg)); +const dryRun = args.includes('--dry-run'); + +if (unknownArgs.length > 0) { + throw new Error(`Unsupported clean argument(s): ${unknownArgs.join(', ')}`); +} + +function resolveCleanTarget(target) { + const targetPath = resolve(packageRoot, target); + const relativePath = relative(packageRoot, targetPath); + + if (relativePath === '' || relativePath.startsWith('..') || isAbsolute(relativePath)) { + throw new Error(`Refusing to clean outside website package root: ${targetPath}`); + } + + return targetPath; +} + +for (const target of cleanTargets) { + const targetPath = resolveCleanTarget(target); + + if (dryRun) { + console.log(`[clean] would remove ${target}`); + continue; + } + + await rm(targetPath, { recursive: true, force: true }); + console.log(`[clean] removed ${target}`); +} diff --git a/website/src/client/camera-control.ts b/website/src/client/camera-control.ts new file mode 100644 index 0000000..a2a31c5 --- /dev/null +++ b/website/src/client/camera-control.ts @@ -0,0 +1,979 @@ +interface CameraControlVector3 { + x: number; + y: number; + z: number; +} + +interface CameraControlEuler extends CameraControlVector3 { + order?: string; + set?: (x: number, y: number, z: number, order?: string) => unknown; +} + +interface CameraControlQuaternion extends CameraControlVector3 { + w: number; +} + +interface CameraControlMatrix4 { + _elements: Float32Array; +} + +interface CameraControlCamera { + position: CameraControlVector3; + rotation: CameraControlEuler; + up?: CameraControlVector3; + quaternion?: CameraControlQuaternion; +} + +interface CameraControlOptions { + enabled?: boolean; + keyboardEnabled?: boolean; + pointerEnabled?: boolean; + orbitEnabled?: boolean; + useOrbit?: boolean; + orbitCenter?: CameraControlVector3; + orbitMinDistance?: number; + moveSpeed?: number; + lookSpeed?: number; + wheelSpeed?: number; + panSpeed?: number; + rollSpeed?: number; + shiftMultiplier?: number; + ctrlMultiplier?: number; + capsMultiplier?: number; +} + +interface CameraControlState { + position: CameraControlVector3; + rotation: CameraControlVector3; + moving: boolean; + rotating: boolean; + panning: boolean; + orbiting: boolean; + touching: boolean; + interacting: boolean; + speedMultiplier: number; + activePointers: number; + orbitCenter: CameraControlVector3; + orbitDistance: number; +} + +type PointerMode = 'rotate' | 'pan' | 'orbit'; + +interface PointerTrack { + pointerId: number; + pointerType: string; + button: number; + mode: PointerMode; + lastX: number; + lastY: number; + x: number; + y: number; +} + +const DEFAULT_OPTIONS: Required = { + enabled: true, + keyboardEnabled: true, + pointerEnabled: true, + orbitEnabled: true, + useOrbit: false, + orbitCenter: { x: 0, y: 0, z: 0 }, + orbitMinDistance: 0.01, + moveSpeed: 0.4, + lookSpeed: 0.004, + wheelSpeed: 0.006, + panSpeed: 0.006, + rollSpeed: 1, + ctrlMultiplier: 2, + shiftMultiplier: 10, + capsMultiplier: 20, +}; + +const KEYBOARD_CONTROL_KEYS = new Set([ + 'KeyW', + 'KeyA', + 'KeyS', + 'KeyD', + 'KeyQ', + 'KeyE', + 'KeyR', + 'KeyF', + 'ShiftLeft', + 'ShiftRight', + 'ControlLeft', + 'ControlRight', + 'AltLeft', + 'AltRight', + 'CapsLock', +]); + +const MAX_PITCH = Math.PI / 2 - 0.001; +const EPSILON = 1e-6; +const DEFAULT_UP: CameraControlVector3 = { x: 0, y: 1, z: 0 }; +const DEFAULT_ORBIT_CENTER: CameraControlVector3 = { x: 0, y: 0, z: 0 }; + +/** + * Lightweight free-flight camera controls for website and Playground preview surfaces. + */ +export class CameraControl { + enabled: boolean; + keyboardEnabled: boolean; + pointerEnabled: boolean; + orbitEnabled: boolean; + useOrbit: boolean; + orbitMinDistance: number; + moveSpeed: number; + lookSpeed: number; + wheelSpeed: number; + panSpeed: number; + rollSpeed: number; + shiftMultiplier: number; + ctrlMultiplier: number; + capsMultiplier: number; + + readonly #camera: CameraControlCamera; + readonly #element: HTMLElement; + readonly #pointers = new Map(); + readonly #keys = new Set(); + readonly #orbitCenter = copyVector(DEFAULT_ORBIT_CENTER); + readonly #initialTabIndex: string | null; + readonly #initialTouchAction: string; + #lastTime = 0; + #wheelDelta = 0; + #capsLock = false; + #altKey = false; + #moving = false; + #rotating = false; + #panning = false; + #orbiting = false; + #disposed = false; + + constructor(camera: CameraControlCamera, element: HTMLElement, options: CameraControlOptions = {}) { + this.#camera = camera; + this.#element = element; + this.#initialTabIndex = element.getAttribute('tabindex'); + this.#initialTouchAction = element.style.touchAction; + + const resolved = { + ...DEFAULT_OPTIONS, + ...options, + }; + + this.enabled = resolved.enabled; + this.keyboardEnabled = resolved.keyboardEnabled; + this.pointerEnabled = resolved.pointerEnabled; + this.orbitEnabled = resolved.orbitEnabled; + this.useOrbit = resolved.useOrbit; + this.orbitMinDistance = Math.max(EPSILON, resolved.orbitMinDistance); + this.setOrbitCenter(resolved.orbitCenter); + this.moveSpeed = resolved.moveSpeed; + this.lookSpeed = resolved.lookSpeed; + this.wheelSpeed = resolved.wheelSpeed; + this.panSpeed = resolved.panSpeed; + this.rollSpeed = resolved.rollSpeed; + this.shiftMultiplier = resolved.shiftMultiplier; + this.ctrlMultiplier = resolved.ctrlMultiplier; + this.capsMultiplier = resolved.capsMultiplier; + + if (this.#initialTabIndex === null) { + element.tabIndex = 0; + } + + element.style.touchAction = 'none'; + element.addEventListener('pointerdown', this.#onPointerDown); + element.addEventListener('pointermove', this.#onPointerMove); + element.addEventListener('pointerup', this.#onPointerUp); + element.addEventListener('pointercancel', this.#onPointerUp); + element.addEventListener('contextmenu', this.#onContextMenu); + element.addEventListener('wheel', this.#onWheel, { passive: false }); + element.addEventListener('keydown', this.#onKeyDown); + element.addEventListener('keyup', this.#onKeyUp); + window.addEventListener('keyup', this.#onKeyUp); + window.addEventListener('blur', this.#onBlur); + } + + setOptions(options: CameraControlOptions) { + const { orbitCenter, ...rest } = options; + + if (orbitCenter !== undefined) { + this.setOrbitCenter(orbitCenter); + } + + for (const [key, value] of Object.entries(rest)) { + if (value !== undefined) { + if (key === 'orbitMinDistance' && typeof value === 'number') { + this.orbitMinDistance = Math.max(EPSILON, value); + } else { + (this as unknown as Record)[key] = value; + } + } + } + } + + setOrbitCenter(center: CameraControlVector3) { + this.#orbitCenter.x = center.x; + this.#orbitCenter.y = center.y; + this.#orbitCenter.z = center.z; + } + + stop() { + for (const pointer of this.#pointers.values()) { + if (this.#element.hasPointerCapture(pointer.pointerId)) { + this.#element.releasePointerCapture(pointer.pointerId); + } + } + + this.#keys.clear(); + this.#pointers.clear(); + this.#wheelDelta = 0; + this.#altKey = false; + this.#moving = false; + this.#rotating = false; + this.#panning = false; + this.#orbiting = false; + } + + update(deltaSeconds?: number) { + if (this.#disposed || !this.enabled) { + return false; + } + + const now = performance.now(); + const delta = deltaSeconds ?? Math.min((now - (this.#lastTime || now)) / 1000, 0.1); + this.#lastTime = now; + + const pointerChanged = this.pointerEnabled ? this.#updatePointers() : false; + const keyboardChanged = this.keyboardEnabled ? this.#updateKeyboard(delta) : false; + const wheelChanged = this.pointerEnabled ? this.#updateWheel() : false; + + return pointerChanged || keyboardChanged || wheelChanged; + } + + getState(): CameraControlState { + const position = this.#camera.position; + const rotation = this.#camera.rotation; + const touching = Array.from(this.#pointers.values()).some(pointer => pointer.pointerType === 'touch'); + const interacting = + this.#moving || this.#rotating || this.#panning || this.#orbiting || this.#pointers.size > 0; + + return { + position: { x: position.x, y: position.y, z: position.z }, + rotation: { x: rotation.x, y: rotation.y, z: rotation.z }, + moving: this.#moving, + rotating: this.#rotating, + panning: this.#panning, + orbiting: this.#orbiting, + touching, + interacting, + speedMultiplier: this.#getSpeedMultiplier(), + activePointers: this.#pointers.size, + orbitCenter: copyVector(this.#orbitCenter), + orbitDistance: this.#getOrbitDistance(), + }; + } + + dispose() { + if (this.#disposed) { + return; + } + + this.stop(); + this.#disposed = true; + this.#element.removeEventListener('pointerdown', this.#onPointerDown); + this.#element.removeEventListener('pointermove', this.#onPointerMove); + this.#element.removeEventListener('pointerup', this.#onPointerUp); + this.#element.removeEventListener('pointercancel', this.#onPointerUp); + this.#element.removeEventListener('contextmenu', this.#onContextMenu); + this.#element.removeEventListener('wheel', this.#onWheel); + this.#element.removeEventListener('keydown', this.#onKeyDown); + this.#element.removeEventListener('keyup', this.#onKeyUp); + window.removeEventListener('keyup', this.#onKeyUp); + window.removeEventListener('blur', this.#onBlur); + this.#element.style.touchAction = this.#initialTouchAction; + + if (this.#initialTabIndex === null) { + this.#element.removeAttribute('tabindex'); + } else { + this.#element.setAttribute('tabindex', this.#initialTabIndex); + } + } + + #onPointerDown = (event: PointerEvent) => { + if (!this.enabled || !this.pointerEnabled) { + return; + } + + this.#element.focus({ preventScroll: true }); + this.#altKey = event.altKey; + this.#pointers.set(event.pointerId, { + pointerId: event.pointerId, + pointerType: event.pointerType, + button: event.button, + mode: this.#getPointerMode(event.pointerType, event.button), + lastX: event.clientX, + lastY: event.clientY, + x: event.clientX, + y: event.clientY, + }); + this.#element.setPointerCapture(event.pointerId); + event.preventDefault(); + }; + + #onPointerMove = (event: PointerEvent) => { + const pointer = this.#pointers.get(event.pointerId); + + if (!pointer) { + return; + } + + this.#altKey = event.altKey; + pointer.x = event.clientX; + pointer.y = event.clientY; + event.preventDefault(); + }; + + #onPointerUp = (event: PointerEvent) => { + if (this.#pointers.has(event.pointerId)) { + this.#pointers.delete(event.pointerId); + + if (this.#element.hasPointerCapture(event.pointerId)) { + this.#element.releasePointerCapture(event.pointerId); + } + + if (this.#pointers.size === 0) { + this.#rotating = false; + this.#panning = false; + this.#orbiting = false; + } + } + }; + + #onContextMenu = (event: MouseEvent) => { + event.preventDefault(); + }; + + #onWheel = (event: WheelEvent) => { + if (!this.enabled || !this.pointerEnabled) { + return; + } + + this.#wheelDelta += event.deltaY; + event.preventDefault(); + }; + + #onKeyDown = (event: KeyboardEvent) => { + if (!this.enabled || !this.keyboardEnabled || !KEYBOARD_CONTROL_KEYS.has(event.code)) { + return; + } + + this.#keys.add(event.code); + this.#capsLock = event.getModifierState('CapsLock'); + this.#altKey = event.altKey || event.code === 'AltLeft' || event.code === 'AltRight'; + event.preventDefault(); + }; + + #onKeyUp = (event: KeyboardEvent) => { + this.#keys.delete(event.code); + this.#capsLock = event.getModifierState('CapsLock'); + this.#altKey = event.altKey; + }; + + #onBlur = () => { + this.stop(); + }; + + #updatePointers() { + const pointers = Array.from(this.#pointers.values()); + this.#rotating = false; + this.#panning = false; + this.#orbiting = false; + + if (pointers.length === 0) { + return false; + } + + let updated = false; + + if (pointers.length >= 2) { + const first = pointers[0]; + const second = pointers[1]; + const lastMidX = (first.lastX + second.lastX) * 0.5; + const lastMidY = (first.lastY + second.lastY) * 0.5; + const midX = (first.x + second.x) * 0.5; + const midY = (first.y + second.y) * 0.5; + const lastDistance = distance(first.lastX, first.lastY, second.lastX, second.lastY); + const currentDistance = distance(first.x, first.y, second.x, second.y); + const panX = midX - lastMidX; + const panY = midY - lastMidY; + const pinch = currentDistance - lastDistance; + + updated = this.#panByPixels(panX, panY) || updated; + updated = this.#moveAlongView(pinch * this.wheelSpeed) || updated; + this.#panning = Math.abs(panX) + Math.abs(panY) + Math.abs(pinch) > 0.001; + } else { + const pointer = pointers[0]; + const mode = this.#getPointerMode(pointer.pointerType, pointer.button); + + if (pointer.mode !== mode) { + pointer.mode = mode; + pointer.lastX = pointer.x; + pointer.lastY = pointer.y; + } + + const deltaX = pointer.x - pointer.lastX; + const deltaY = pointer.y - pointer.lastY; + + if (mode === 'pan') { + updated = this.#panByPixels(deltaX, deltaY); + this.#panning = updated; + } else if (mode === 'orbit') { + updated = this.#orbitByPixels(deltaX, deltaY); + this.#orbiting = true; + } else { + updated = this.#rotateByPixels(deltaX, deltaY); + this.#rotating = updated; + } + } + + for (const pointer of pointers) { + pointer.lastX = pointer.x; + pointer.lastY = pointer.y; + } + + return updated; + } + + #updateKeyboard(deltaSeconds: number) { + const forwardInput = numberFromKey(this.#keys, 'KeyW') - numberFromKey(this.#keys, 'KeyS'); + const strafeInput = numberFromKey(this.#keys, 'KeyD') - numberFromKey(this.#keys, 'KeyA'); + const verticalInput = numberFromKey(this.#keys, 'KeyQ') - numberFromKey(this.#keys, 'KeyE'); + const rollInput = numberFromKey(this.#keys, 'KeyR') - numberFromKey(this.#keys, 'KeyF'); + const multiplier = this.#getSpeedMultiplier(); + let updated = false; + + const movementLength = Math.hypot(forwardInput, strafeInput, verticalInput); + this.#moving = movementLength > 0; + + if (movementLength > 0) { + const scale = (this.moveSpeed * multiplier * deltaSeconds) / Math.max(1, movementLength); + const basis = getCameraBasis(this.#camera); + const position = this.#camera.position; + + position.x += + (basis.forward.x * forwardInput + basis.right.x * strafeInput + basis.up.x * verticalInput) * scale; + position.y += + (basis.forward.y * forwardInput + basis.right.y * strafeInput + basis.up.y * verticalInput) * scale; + position.z += + (basis.forward.z * forwardInput + basis.right.z * strafeInput + basis.up.z * verticalInput) * scale; + updated = true; + } + + if (rollInput !== 0) { + rollCamera(this.#camera, rollInput * this.rollSpeed * deltaSeconds); + this.#rotating = true; + updated = true; + } else if (this.#pointers.size === 0) { + this.#rotating = false; + } + + return updated; + } + + #updateWheel() { + if (Math.abs(this.#wheelDelta) < 0.001) { + return false; + } + + const delta = -this.#wheelDelta * this.wheelSpeed; + this.#wheelDelta = 0; + return this.#moveAlongView(delta); + } + + #rotateByPixels(deltaX: number, deltaY: number) { + if (Math.abs(deltaX) + Math.abs(deltaY) < 0.001) { + return false; + } + + rotateCamera(this.#camera, -deltaX * this.lookSpeed, -deltaY * this.lookSpeed); + return true; + } + + #orbitByPixels(deltaX: number, deltaY: number) { + if (Math.abs(deltaX) + Math.abs(deltaY) < 0.001) { + return false; + } + + return orbitCamera( + this.#camera, + this.#orbitCenter, + -deltaX * this.lookSpeed, + -deltaY * this.lookSpeed, + this.orbitMinDistance, + ); + } + + #panByPixels(deltaX: number, deltaY: number) { + if (Math.abs(deltaX) + Math.abs(deltaY) < 0.001) { + return false; + } + + const basis = getCameraBasis(this.#camera); + const position = this.#camera.position; + const x = deltaX * this.panSpeed; + const y = -deltaY * this.panSpeed; + + position.x += basis.right.x * x + basis.viewUp.x * y; + position.y += basis.right.y * x + basis.viewUp.y * y; + position.z += basis.right.z * x + basis.viewUp.z * y; + return true; + } + + #moveAlongView(distanceValue: number) { + if (Math.abs(distanceValue) < 0.001) { + return false; + } + + const forward = getCameraBasis(this.#camera).forward; + const position = this.#camera.position; + position.x += forward.x * distanceValue; + position.y += forward.y * distanceValue; + position.z += forward.z * distanceValue; + return true; + } + + #getSpeedMultiplier() { + let multiplier = 1; + + if (this.#keys.has('ShiftLeft') || this.#keys.has('ShiftRight')) { + multiplier *= this.shiftMultiplier; + } + + if (this.#keys.has('ControlLeft') || this.#keys.has('ControlRight')) { + multiplier *= this.ctrlMultiplier; + } + + if (this.#capsLock || this.#keys.has('CapsLock')) { + multiplier *= this.capsMultiplier; + } + + return multiplier; + } + + #getPointerMode(pointerType: string, button: number): PointerMode { + if (pointerType === 'mouse') { + if (button === 1 || button === 2) { + return 'pan'; + } + + if (button === 0 && this.orbitEnabled && (this.useOrbit || this.#isOrbitModifierActive())) { + return 'orbit'; + } + } + + return 'rotate'; + } + + #isOrbitModifierActive() { + return this.#altKey || this.#keys.has('AltLeft') || this.#keys.has('AltRight'); + } + + #getOrbitDistance() { + return lengthVector(subtractVectors(this.#camera.position, this.#orbitCenter)); + } +} + +function rotateCamera(camera: CameraControlCamera, yawDelta: number, pitchDelta: number) { + const basis = getCameraBasis(camera); + const orientation = getCameraOrientationBasis(camera); + const rollAngle = getForwardAxisRoll(orientation.forward, orientation.up, orientation.viewUp); + const currentPitch = Math.asin(clamp(dotVector(basis.forward, basis.up), -1, 1)); + const nextPitch = clamp(currentPitch + pitchDelta, -MAX_PITCH, MAX_PITCH); + const horizontalForward = + normalizeVector(projectOnPlane(basis.forward, basis.up)) ?? getPerpendicularUnit(basis.up); + const yawedForward = rotateVectorAroundAxis(horizontalForward, basis.up, yawDelta); + const forward = normalizeVector( + addVectors(multiplyVector(yawedForward, Math.cos(nextPitch)), multiplyVector(basis.up, Math.sin(nextPitch))), + ); + + if (!forward) { + return; + } + + setCameraLookBasis(camera, forward, getViewUpWithRoll(forward, basis.up, rollAngle)); +} + +function orbitCamera( + camera: CameraControlCamera, + center: CameraControlVector3, + yawDelta: number, + pitchDelta: number, + minDistance: number, +) { + const basis = getCameraBasis(camera); + const orientation = getCameraOrientationBasis(camera); + const rollAngle = getForwardAxisRoll(orientation.forward, orientation.up, orientation.viewUp); + const safeMinDistance = Math.max(EPSILON, minDistance); + let distanceValue = lengthVector(subtractVectors(camera.position, center)); + let forward = normalizeVector(subtractVectors(center, camera.position)) ?? + normalizeVector(orientation.forward) ?? { x: 0, y: 0, z: -1 }; + + if (distanceValue < safeMinDistance) { + distanceValue = safeMinDistance; + forward = normalizeVector(orientation.forward) ?? forward; + } + + const currentPitch = Math.asin(clamp(dotVector(forward, basis.up), -1, 1)); + const nextPitch = clamp(currentPitch + pitchDelta, -MAX_PITCH, MAX_PITCH); + const horizontalForward = normalizeVector(projectOnPlane(forward, basis.up)) ?? getPerpendicularUnit(basis.up); + const yawedForward = rotateVectorAroundAxis(horizontalForward, basis.up, yawDelta); + const nextForward = normalizeVector( + addVectors(multiplyVector(yawedForward, Math.cos(nextPitch)), multiplyVector(basis.up, Math.sin(nextPitch))), + ); + + if (!nextForward) { + return false; + } + + camera.position.x = center.x - nextForward.x * distanceValue; + camera.position.y = center.y - nextForward.y * distanceValue; + camera.position.z = center.z - nextForward.z * distanceValue; + setCameraLookBasis(camera, nextForward, getViewUpWithRoll(nextForward, basis.up, rollAngle)); + return true; +} + +function rollCamera(camera: CameraControlCamera, rollDelta: number) { + const basis = getCameraOrientationBasis(camera); + const viewUp = normalizeVector(rotateVectorAroundAxis(basis.viewUp, basis.forward, -rollDelta)); + + if (!viewUp) { + return; + } + + setCameraLookBasis(camera, basis.forward, viewUp); +} + +function getCameraBasis(camera: CameraControlCamera) { + const orientation = getCameraOrientationBasis(camera); + const right = normalizeVector(crossVectors(orientation.forward, orientation.up)) ?? orientation.right; + const viewUp = normalizeVector(crossVectors(right, orientation.forward)) ?? orientation.viewUp; + + return { + forward: orientation.forward, + right, + up: orientation.up, + viewUp, + }; +} + +function getCameraOrientationBasis(camera: CameraControlCamera) { + const up = getCameraUp(camera); + const matrix = getCameraRotationMatrix(camera); + const elements = matrix._elements; + const forward = normalizeVector({ + x: -elements[8], + y: -elements[9], + z: -elements[10], + }) ?? { x: 0, y: 0, z: -1 }; + const matrixRight = + normalizeVector({ + x: elements[0], + y: elements[1], + z: elements[2], + }) ?? getPerpendicularUnit(up); + const viewUp = + normalizeVector({ + x: elements[4], + y: elements[5], + z: elements[6], + }) ?? + normalizeVector(crossVectors(matrixRight, forward)) ?? + up; + + return { + forward, + right: matrixRight, + up, + viewUp, + }; +} + +function getForwardAxisRoll(forward: CameraControlVector3, up: CameraControlVector3, viewUp: CameraControlVector3) { + const levelViewUp = getViewUpWithRoll(forward, up, 0); + return angleAroundAxis(levelViewUp, viewUp, forward); +} + +function getViewUpWithRoll(forward: CameraControlVector3, up: CameraControlVector3, rollAngle: number) { + const right = normalizeVector(crossVectors(forward, up)) ?? getPerpendicularUnit(forward); + const viewUp = normalizeVector(crossVectors(right, forward)) ?? up; + return normalizeVector(rotateVectorAroundAxis(viewUp, forward, rollAngle)) ?? viewUp; +} + +function setCameraLookBasis(camera: CameraControlCamera, forward: CameraControlVector3, up: CameraControlVector3) { + const right = normalizeVector(crossVectors(forward, up)) ?? getPerpendicularUnit(up); + const viewUp = normalizeVector(crossVectors(right, forward)) ?? up; + const back = multiplyVector(forward, -1); + const matrix = makeBasisMatrix(right, viewUp, back); + const rotation = camera.rotation; + const matrixRotation = rotation as CameraControlEuler & { + setFromRotationMatrix?: (matrix: CameraControlMatrix4, order?: string) => unknown; + }; + + if (typeof matrixRotation.setFromRotationMatrix === 'function') { + matrixRotation.setFromRotationMatrix(matrix, rotation.order); + return; + } + + const euler = getEulerFromRotationMatrix(matrix, rotation.order ?? 'XYZ'); + + if (typeof rotation.set === 'function') { + rotation.set(euler.x, euler.y, euler.z, euler.order); + } else { + rotation.x = euler.x; + rotation.y = euler.y; + rotation.z = euler.z; + } +} + +function getCameraUp(camera: CameraControlCamera) { + return normalizeVector(camera.up ?? DEFAULT_UP) ?? DEFAULT_UP; +} + +function getCameraRotationMatrix(camera: CameraControlCamera) { + if (camera.quaternion) { + return makeRotationMatrixFromQuaternion(camera.quaternion); + } + + return makeRotationMatrixFromEuler(camera.rotation); +} + +function makeRotationMatrixFromQuaternion(quaternion: CameraControlQuaternion): CameraControlMatrix4 { + const { x, y, z, w } = quaternion; + const x2 = x + x; + const y2 = y + y; + const z2 = z + z; + const xx = x * x2; + const xy = x * y2; + const xz = x * z2; + const yy = y * y2; + const yz = y * z2; + const zz = z * z2; + const wx = w * x2; + const wy = w * y2; + const wz = w * z2; + + return makeMatrix([ + 1 - (yy + zz), + xy + wz, + xz - wy, + 0, + xy - wz, + 1 - (xx + zz), + yz + wx, + 0, + xz + wy, + yz - wx, + 1 - (xx + yy), + 0, + 0, + 0, + 0, + 1, + ]); +} + +function makeRotationMatrixFromEuler(rotation: CameraControlEuler): CameraControlMatrix4 { + const x = rotation.x; + const y = rotation.y; + const z = rotation.z; + const a = Math.cos(x); + const b = Math.sin(x); + const c = Math.cos(y); + const d = Math.sin(y); + const e = Math.cos(z); + const f = Math.sin(z); + const ae = a * e; + const af = a * f; + const be = b * e; + const bf = b * f; + + return makeMatrix([ + c * e, + af + be * d, + bf - ae * d, + 0, + -c * f, + ae - bf * d, + be + af * d, + 0, + d, + -b * c, + a * c, + 0, + 0, + 0, + 0, + 1, + ]); +} + +function makeBasisMatrix( + xAxis: CameraControlVector3, + yAxis: CameraControlVector3, + zAxis: CameraControlVector3, +): CameraControlMatrix4 { + return makeMatrix([ + xAxis.x, + xAxis.y, + xAxis.z, + 0, + yAxis.x, + yAxis.y, + yAxis.z, + 0, + zAxis.x, + zAxis.y, + zAxis.z, + 0, + 0, + 0, + 0, + 1, + ]); +} + +function makeMatrix(elements: number[]): CameraControlMatrix4 { + return { + _elements: Float32Array.from(elements), + }; +} + +function getEulerFromRotationMatrix(matrix: CameraControlMatrix4, order: string) { + const te = matrix._elements; + const m11 = te[0]; + const m12 = te[4]; + const m13 = te[8]; + const m23 = te[9]; + const m33 = te[10]; + const euler = { + x: 0, + y: Math.asin(clamp(m13, -1, 1)), + z: 0, + order, + }; + + if (Math.abs(m13) < 0.99999) { + euler.x = Math.atan2(-m23, m33); + euler.z = Math.atan2(-m12, m11); + } else { + const m22 = te[5]; + const m32 = te[6]; + euler.x = Math.atan2(m32, m22); + } + + return euler; +} + +function projectOnPlane(vector: CameraControlVector3, normal: CameraControlVector3) { + return subtractVectors(vector, multiplyVector(normal, dotVector(vector, normal))); +} + +function angleAroundAxis(from: CameraControlVector3, to: CameraControlVector3, axis: CameraControlVector3) { + const projectedFrom = normalizeVector(projectOnPlane(from, axis)); + const projectedTo = normalizeVector(projectOnPlane(to, axis)); + + if (!projectedFrom || !projectedTo) { + return 0; + } + + return Math.atan2(dotVector(crossVectors(projectedFrom, projectedTo), axis), dotVector(projectedFrom, projectedTo)); +} + +function rotateVectorAroundAxis(vector: CameraControlVector3, axis: CameraControlVector3, angle: number) { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const cross = crossVectors(axis, vector); + const axisScale = dotVector(axis, vector) * (1 - cos); + + return addVectors( + addVectors(multiplyVector(vector, cos), multiplyVector(cross, sin)), + multiplyVector(axis, axisScale), + ); +} + +function getPerpendicularUnit(axis: CameraControlVector3) { + const helper = Math.abs(axis.y) < 0.9 ? { x: 0, y: 1, z: 0 } : { x: 1, y: 0, z: 0 }; + return normalizeVector(crossVectors(axis, helper)) ?? { x: 1, y: 0, z: 0 }; +} + +function addVectors(a: CameraControlVector3, b: CameraControlVector3) { + return { + x: a.x + b.x, + y: a.y + b.y, + z: a.z + b.z, + }; +} + +function subtractVectors(a: CameraControlVector3, b: CameraControlVector3) { + return { + x: a.x - b.x, + y: a.y - b.y, + z: a.z - b.z, + }; +} + +function multiplyVector(vector: CameraControlVector3, scale: number) { + return { + x: vector.x * scale, + y: vector.y * scale, + z: vector.z * scale, + }; +} + +function copyVector(vector: CameraControlVector3) { + return { + x: vector.x, + y: vector.y, + z: vector.z, + }; +} + +function lengthVector(vector: CameraControlVector3) { + return Math.hypot(vector.x, vector.y, vector.z); +} + +function dotVector(a: CameraControlVector3, b: CameraControlVector3) { + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +function crossVectors(a: CameraControlVector3, b: CameraControlVector3) { + return { + x: a.y * b.z - a.z * b.y, + y: a.z * b.x - a.x * b.z, + z: a.x * b.y - a.y * b.x, + }; +} + +function normalizeVector(vector: CameraControlVector3) { + const length = Math.hypot(vector.x, vector.y, vector.z); + + if (length < EPSILON) { + return undefined; + } + + return { + x: vector.x / length, + y: vector.y / length, + z: vector.z / length, + }; +} + +function numberFromKey(keys: Set, code: string) { + return keys.has(code) ? 1 : 0; +} + +function distance(x1: number, y1: number, x2: number, y2: number) { + return Math.hypot(x2 - x1, y2 - y1); +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(Math.max(value, minimum), maximum); +} diff --git a/website/src/client/docs.ts b/website/src/client/docs.ts new file mode 100644 index 0000000..5183cfd --- /dev/null +++ b/website/src/client/docs.ts @@ -0,0 +1,110 @@ +interface DocsCodeToolsConfig { + copyLabel: string; + copiedLabel: string; + failedLabel: string; +} + +const copyTargetsSelector = '.markdown-body pre, .typedoc-html pre'; +const resetTimers = new WeakMap(); + +export function mountDocsCodeTools(config: DocsCodeToolsConfig) { + const targets = Array.from(document.querySelectorAll(copyTargetsSelector)); + + for (const target of targets) { + if (target.closest('[data-code-block-copy]') || !getCodeText(target).trim()) { + continue; + } + + const shell = document.createElement('div'); + shell.className = 'code-block-shell'; + shell.dataset.codeBlockCopy = ''; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'code-copy-button'; + button.dataset.copyState = 'idle'; + setButtonState(button, config.copyLabel, 'idle'); + button.addEventListener('click', () => { + void copyCode(target, button, config); + }); + + target.before(shell); + shell.append(target, button); + } +} + +async function copyCode(target: HTMLElement, button: HTMLButtonElement, config: DocsCodeToolsConfig) { + const text = getCodeText(target); + + try { + await writeClipboard(text); + setButtonState(button, config.copiedLabel, 'copied'); + scheduleReset(button, config.copyLabel); + } catch { + setButtonState(button, config.failedLabel, 'failed'); + scheduleReset(button, config.copyLabel); + } +} + +function getCodeText(target: HTMLElement) { + return target.querySelector('code')?.textContent ?? target.textContent ?? ''; +} + +async function writeClipboard(text: string) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall back for browsers that expose Clipboard API but deny this call. + } + } + + const textarea = document.createElement('textarea'); + const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; + + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '-999px'; + textarea.style.left = '-999px'; + textarea.style.width = '1px'; + textarea.style.height = '1px'; + textarea.style.opacity = '0'; + document.body.append(textarea); + textarea.focus({ preventScroll: true }); + textarea.select(); + textarea.setSelectionRange(0, text.length); + + const clipboardFallback = document as unknown as { execCommand(commandId: string): boolean }; + const copied = clipboardFallback.execCommand('copy'); + textarea.remove(); + activeElement?.focus({ preventScroll: true }); + + if (!copied) { + throw new Error('Copy failed'); + } +} + +function scheduleReset(button: HTMLButtonElement, copyLabel: string) { + const existingTimer = resetTimers.get(button); + + if (existingTimer !== undefined) { + window.clearTimeout(existingTimer); + } + + resetTimers.set( + button, + window.setTimeout(() => { + setButtonState(button, copyLabel, 'idle'); + resetTimers.delete(button); + }, 1600), + ); +} + +function setButtonState(button: HTMLButtonElement, label: string, state: string) { + button.textContent = label; + button.title = label; + button.setAttribute('aria-label', label); + button.dataset.copyState = state; +} diff --git a/website/src/client/examples.ts b/website/src/client/examples.ts new file mode 100644 index 0000000..15000ae --- /dev/null +++ b/website/src/client/examples.ts @@ -0,0 +1,284 @@ +import { createRenderSession } from './render-runtime.js'; +import { mountSplitPane } from './split-pane.js'; +import { WORKSPACE_FULLSCREEN_CHANGE_EVENT, mountWorkspaceFullscreenMode } from './workspace-fullscreen.js'; + +interface PreviewEmbedConfig { + accent: string; + configLabel: string; + code: string; + errorLabel: string; + loadingLabel: string; + renderer: { + antialiasing: boolean; + pixelRatio?: number; + }; +} + +export function mountAllPreviewEmbeds(root: ParentNode = document) { + for (const previewRoot of root.querySelectorAll('[data-example-preview]')) { + mountPreviewEmbed(previewRoot); + } +} + +function mountPreviewEmbed(root: HTMLElement) { + if (root.dataset.mounted === 'true') { + return; + } + + root.dataset.mounted = 'true'; + + const canvas = root.querySelector('[data-example-preview-canvas]'); + const configPanel = root.querySelector('[data-example-config-panel]'); + const configElement = root.querySelector('[data-example-preview-config]'); + const status = root.querySelector('[data-example-preview-status]'); + + if (!canvas || !configElement?.textContent) { + return; + } + + const previewCanvas = canvas; + const config = JSON.parse(configElement.textContent) as PreviewEmbedConfig; + let renderSession: Awaited> | undefined; + let renderAbortController: AbortController | undefined; + let renderVersion = 0; + let resizeTimer: number | undefined; + let disposed = false; + + runEmbedSession(); + + const resizeObserver = new ResizeObserver(() => { + scheduleSessionResize(); + }); + resizeObserver.observe(root); + + window.addEventListener(WORKSPACE_FULLSCREEN_CHANGE_EVENT, scheduleSessionResize); + window.addEventListener('beforeunload', dispose, { once: true }); + document.addEventListener('astro:before-swap', dispose, { once: true }); + + function scheduleSessionResize() { + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + renderSession?.resize(); + }, 120); + } + + async function runEmbedSession() { + if (disposed) { + return; + } + + const version = renderVersion + 1; + renderVersion = version; + renderAbortController?.abort(); + renderAbortController = new AbortController(); + renderSession?.dispose(); + renderSession = undefined; + setPreviewState('loading', config.loadingLabel); + + try { + const session = await createRenderSession(previewCanvas, config.code, config.accent, { + configPanel: configPanel + ? { + container: configPanel, + title: config.configLabel, + } + : undefined, + signal: renderAbortController.signal, + renderer: config.renderer, + onStatus(nextStatus) { + if (disposed || version !== renderVersion) { + return; + } + + if (nextStatus.state === 'loading') { + setPreviewState('loading', nextStatus.label ?? config.loadingLabel); + } else { + setPreviewState('ready'); + } + }, + }); + + if (disposed || version !== renderVersion) { + session.dispose(); + return; + } + + renderSession = session; + renderAbortController = undefined; + } catch (error) { + if (disposed || version !== renderVersion) { + return; + } + + const message = error instanceof Error ? error.message : String(error); + renderAbortController = undefined; + setPreviewState('error', `${config.errorLabel}: ${message}`); + + console.error(error); + } + } + + function dispose() { + disposed = true; + window.clearTimeout(resizeTimer); + resizeObserver.disconnect(); + window.removeEventListener(WORKSPACE_FULLSCREEN_CHANGE_EVENT, scheduleSessionResize); + renderAbortController?.abort(); + renderAbortController = undefined; + renderSession?.dispose(); + renderSession = undefined; + } + + function setPreviewState(state: 'loading' | 'ready' | 'error', label?: string) { + root.dataset.state = state; + root.setAttribute('aria-busy', String(state === 'loading')); + + if (!status) { + return; + } + + if (state === 'ready') { + status.hidden = true; + status.textContent = ''; + return; + } + + status.hidden = false; + status.textContent = label ?? config.loadingLabel; + } +} + +const collapsedStorageKey = 'aholo:examples:rail-collapsed'; +const widthStorageKey = 'aholo:examples:rail-width'; +const defaultRailWidth = 286; +const railCollapseThreshold = 160; +const railMinWidth = 220; +const railMaxWidth = 420; +const stageMinWidth = 420; +const splitterWidth = 9; + +export function mountExamplesPage(root: ParentNode = document) { + const viewer = root.querySelector('[data-example-viewer]'); + const splitter = root.querySelector('[data-example-splitter]'); + + if (!viewer || !splitter || viewer.dataset.mounted === 'true') { + return; + } + + viewer.dataset.mounted = 'true'; + + const isCompactLayout = () => window.matchMedia('(max-width: 900px)').matches; + let disposed = false; + const splitPane = mountSplitPane({ + container: viewer, + splitter, + collapsedDatasetKey: 'paneCollapsed', + cssProperty: '--example-rail-width', + defaultValue: readRailWidth(), + valueUnit: 'px', + keyboardStep: 24, + isDisabled: isCompactLayout, + toggleWhenDisabled: true, + clampValue(value, rect) { + const maximum = getMaximumRailWidth(rect.width); + return clamp(value, Math.min(railMinWidth, maximum), maximum); + }, + shouldCollapse(value, rect) { + return value <= Math.min(railCollapseThreshold, rect.width * 0.24); + }, + getAriaValue(value, rect) { + return rect.width > 0 ? (value / rect.width) * 100 : 0; + }, + getInitialCollapsed: readCollapsed, + onCollapsedChange(collapsed, { persist }) { + if (persist) { + writeCollapsed(collapsed); + } + }, + onValueChange(value, { persist }) { + if (persist) { + writeRailWidth(value); + } + }, + }); + const fullscreenMode = mountWorkspaceFullscreenMode({ + onChange() { + handleResize(); + }, + }); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + viewer.dataset.layoutReady = 'true'; + }); + }); + + window.addEventListener('resize', handleResize); + window.addEventListener('beforeunload', dispose, { once: true }); + document.addEventListener('astro:before-swap', dispose, { once: true }); + + function handleResize() { + if (!splitPane.getCollapsed() && !isCompactLayout()) { + splitPane.setSize(splitPane.getSize(), { persist: false }); + } + } + + function dispose() { + if (disposed) { + return; + } + + disposed = true; + fullscreenMode.dispose(); + splitPane.dispose(); + window.removeEventListener('resize', handleResize); + window.removeEventListener('beforeunload', dispose); + document.removeEventListener('astro:before-swap', dispose); + } +} + +function getMaximumRailWidth(viewerWidth: number) { + return Math.max(railMinWidth, Math.min(railMaxWidth, viewerWidth - stageMinWidth - splitterWidth)); +} + +function readCollapsed() { + try { + return localStorage.getItem(collapsedStorageKey) === 'true'; + } catch { + return false; + } +} + +function writeCollapsed(collapsed: boolean) { + try { + localStorage.setItem(collapsedStorageKey, String(collapsed)); + } catch { + // Persisting the rail state is optional; resizing should still work. + } +} + +function readRailWidth() { + try { + const stored = Number(localStorage.getItem(widthStorageKey)); + + if (Number.isFinite(stored) && stored > 0) { + return stored; + } + } catch { + // Persisting the rail width is optional; the default width is fine. + } + + return defaultRailWidth; +} + +function writeRailWidth(width: number) { + try { + localStorage.setItem(widthStorageKey, String(Math.round(width))); + } catch { + // Persisting the rail width is optional; resizing should still work. + } +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(Math.max(value, minimum), maximum); +} diff --git a/website/src/client/home.ts b/website/src/client/home.ts new file mode 100644 index 0000000..84d3f66 --- /dev/null +++ b/website/src/client/home.ts @@ -0,0 +1,235 @@ +import { createRenderSession } from './render-runtime.js'; + +const HOME_INTERACTION_ENTER_EVENT = 'aholo:home-interaction-enter'; + +interface HomeStageConfig { + code: string; +} + +export function mountHomeStage(stage: HTMLElement, config: HomeStageConfig) { + if (stage.dataset.mounted === 'true') { + return; + } + + const canvas = stage.querySelector('[data-home-preview]'); + const enterButtons = Array.from(stage.querySelectorAll('[data-home-enter]')); + const exitButton = stage.querySelector('[data-home-exit]'); + + if (!canvas) { + return; + } + + const previewCanvas = canvas; + stage.dataset.mounted = 'true'; + + let previewTiltX = 0; + let previewTiltY = 0; + let dragStartX = 0; + let dragStartY = 0; + let dragBaseX = 0; + let dragBaseY = 0; + let dragging = false; + let renderSession: Awaited> | undefined; + let renderVersion = 0; + let transitionTimer: number | undefined; + let resizeFrame: number | undefined; + let disposed = false; + + renderHomePreview(); + setHomeInteractive(false); + + for (const enterButton of enterButtons) { + enterButton.addEventListener('click', handleEnter); + } + exitButton?.addEventListener('click', handleExit); + document.addEventListener('keydown', handleKeydown); + previewCanvas.addEventListener('pointerdown', handlePointerDown); + previewCanvas.addEventListener('pointermove', handlePointerMove); + previewCanvas.addEventListener('pointerup', handlePointerUp); + previewCanvas.addEventListener('pointercancel', handlePointerCancel); + + // Theme switches must not reload the splat scene; the accent only feeds + // the surface CSS variable, so update it in place. + const themeObserver = new MutationObserver(() => { + renderSession?.setAccent(readThemeAccent()); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }); + + // The stage sits in the first viewport only; stop the render loop while it + // is scrolled out of view so scrolling the rest of the page stays idle. + let stageVisible = true; + const stageVisibilityObserver = new IntersectionObserver(entries => { + stageVisible = entries[entries.length - 1]?.isIntersecting ?? true; + renderSession?.setPaused(!stageVisible); + }); + stageVisibilityObserver.observe(stage); + + window.addEventListener('pageshow', handlePageShow); + window.addEventListener('resize', schedulePreviewResize); + document.addEventListener('astro:before-swap', dispose, { once: true }); + + async function renderHomePreview() { + const version = renderVersion + 1; + renderVersion = version; + renderSession?.dispose(); + renderSession = undefined; + + try { + const session = await createRenderSession(previewCanvas, config.code, readThemeAccent()); + + if (disposed || version !== renderVersion) { + session.dispose(); + return; + } + + renderSession = session; + session.setAccent(readThemeAccent()); + session.setPaused(!stageVisible); + } catch (error) { + console.error(error); + } + } + + function handleEnter() { + setHomeInteractive(true, { animate: true }); + document.dispatchEvent(new Event(HOME_INTERACTION_ENTER_EVENT)); + } + + function handleExit() { + setHomeInteractive(false); + } + + function handleKeydown(event: KeyboardEvent) { + if (event.key === 'Escape' && stage.dataset.interactive === 'true') { + setHomeInteractive(false); + } + } + + function handlePointerDown(event: PointerEvent) { + if (stage.dataset.interactive !== 'true') { + return; + } + + dragging = true; + dragStartX = event.clientX; + dragStartY = event.clientY; + dragBaseX = previewTiltX; + dragBaseY = previewTiltY; + previewCanvas.setPointerCapture(event.pointerId); + } + + function handlePointerMove(event: PointerEvent) { + if (!dragging) { + return; + } + + previewTiltY = clamp(dragBaseY + (event.clientX - dragStartX) * 0.04, -10, 10); + previewTiltX = clamp(dragBaseX - (event.clientY - dragStartY) * 0.035, -7, 7); + stage.style.setProperty('--home-tilt-x', `${previewTiltX}deg`); + stage.style.setProperty('--home-tilt-y', `${previewTiltY}deg`); + } + + function handlePointerUp(event: PointerEvent) { + dragging = false; + + if (previewCanvas.hasPointerCapture(event.pointerId)) { + previewCanvas.releasePointerCapture(event.pointerId); + } + } + + function handlePointerCancel() { + dragging = false; + } + + function handlePageShow(event: PageTransitionEvent) { + if (event.persisted) { + schedulePreviewResize(); + } + } + + function schedulePreviewResize() { + if (resizeFrame !== undefined) { + window.cancelAnimationFrame(resizeFrame); + } + + resizeFrame = window.requestAnimationFrame(() => { + resizeFrame = undefined; + renderSession?.resize(); + }); + } + + function dispose() { + if (disposed) { + return; + } + + disposed = true; + renderVersion += 1; + window.clearTimeout(transitionTimer); + + if (resizeFrame !== undefined) { + window.cancelAnimationFrame(resizeFrame); + resizeFrame = undefined; + } + + setHomeInteractive(false, { resize: false }); + for (const enterButton of enterButtons) { + enterButton.removeEventListener('click', handleEnter); + } + exitButton?.removeEventListener('click', handleExit); + document.removeEventListener('keydown', handleKeydown); + previewCanvas.removeEventListener('pointerdown', handlePointerDown); + previewCanvas.removeEventListener('pointermove', handlePointerMove); + previewCanvas.removeEventListener('pointerup', handlePointerUp); + previewCanvas.removeEventListener('pointercancel', handlePointerCancel); + window.removeEventListener('pageshow', handlePageShow); + window.removeEventListener('resize', schedulePreviewResize); + document.removeEventListener('astro:before-swap', dispose); + themeObserver.disconnect(); + stageVisibilityObserver.disconnect(); + renderSession?.dispose(); + renderSession = undefined; + delete stage.dataset.mounted; + } + + function readThemeAccent() { + return getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#18181b'; + } + + function setHomeInteractive(interactive: boolean, options: { animate?: boolean; resize?: boolean } = {}) { + window.clearTimeout(transitionTimer); + + if (interactive && options.animate) { + stage.dataset.transition = 'enter'; + transitionTimer = window.setTimeout(() => { + if (stage.dataset.transition === 'enter') { + delete stage.dataset.transition; + } + }, 860); + } else { + delete stage.dataset.transition; + } + + stage.dataset.interactive = interactive ? 'true' : 'false'; + document.documentElement.classList.toggle('home-interactive', interactive); + document.body.classList.toggle('home-interactive', interactive); + for (const enterButton of enterButtons) { + enterButton.setAttribute('aria-expanded', interactive ? 'true' : 'false'); + } + + if (exitButton) { + exitButton.hidden = !interactive; + } + + if (options.resize !== false) { + schedulePreviewResize(); + } + } +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} diff --git a/website/src/client/interaction-guide.ts b/website/src/client/interaction-guide.ts new file mode 100644 index 0000000..1206304 --- /dev/null +++ b/website/src/client/interaction-guide.ts @@ -0,0 +1,100 @@ +const GUIDE_SELECTOR = '[data-interaction-guide]'; +const MIN_TRIGGER_DISTANCE = 88; +const MAX_TRIGGER_DISTANCE = 152; +const TRIGGER_HEIGHT_RATIO = 0.24; +const TOUCH_HIDE_DELAY = 1800; + +export function mountInteractionGuides(root: ParentNode = document) { + for (const guide of root.querySelectorAll(GUIDE_SELECTOR)) { + mountInteractionGuide(guide); + } +} + +function mountInteractionGuide(guide: HTMLElement) { + if (guide.dataset.mounted === 'true') { + return; + } + + const surface = guide.parentElement; + + if (!surface) { + return; + } + + const surfaceElement = surface; + + guide.dataset.mounted = 'true'; + + let hideTimer: number | undefined; + const stateObserver = new MutationObserver(() => { + if (!isEnabled()) { + hide(); + } + }); + + surfaceElement.addEventListener('pointermove', handlePointerMove); + surfaceElement.addEventListener('pointerleave', hide); + surfaceElement.addEventListener('pointercancel', hide); + window.addEventListener('blur', hide); + document.addEventListener('astro:before-swap', dispose, { once: true }); + + if (guide.dataset.activeWhen === 'interactive') { + stateObserver.observe(surfaceElement, { + attributes: true, + attributeFilter: ['data-interactive'], + }); + } + + function handlePointerMove(event: PointerEvent) { + window.clearTimeout(hideTimer); + + if (!isEnabled()) { + hide(); + return; + } + + const rect = surfaceElement.getBoundingClientRect(); + const triggerDistance = clamp(rect.height * TRIGGER_HEIGHT_RATIO, MIN_TRIGGER_DISTANCE, MAX_TRIGGER_DISTANCE); + const insideHorizontal = event.clientX >= rect.left && event.clientX <= rect.right; + const nearBottom = + insideHorizontal && event.clientY >= rect.bottom - triggerDistance && event.clientY <= rect.bottom; + + if (nearBottom) { + show(); + + if (event.pointerType === 'touch') { + hideTimer = window.setTimeout(hide, TOUCH_HIDE_DELAY); + } + } else { + hide(); + } + } + + function isEnabled() { + return guide.dataset.activeWhen !== 'interactive' || surfaceElement.dataset.interactive === 'true'; + } + + function show() { + guide.dataset.visible = 'true'; + } + + function hide() { + window.clearTimeout(hideTimer); + hideTimer = undefined; + delete guide.dataset.visible; + } + + function dispose() { + hide(); + surfaceElement.removeEventListener('pointermove', handlePointerMove); + surfaceElement.removeEventListener('pointerleave', hide); + surfaceElement.removeEventListener('pointercancel', hide); + window.removeEventListener('blur', hide); + stateObserver.disconnect(); + delete guide.dataset.mounted; + } +} + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(Math.max(value, minimum), maximum); +} diff --git a/website/src/client/playground.ts b/website/src/client/playground.ts new file mode 100644 index 0000000..4593b9f --- /dev/null +++ b/website/src/client/playground.ts @@ -0,0 +1,749 @@ +import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'; +import { Pane } from 'tweakpane'; +import * as TweakpaneEssentialsPlugin from '@tweakpane/plugin-essentials'; +import type { RenderSession } from './render-runtime.js'; +import { createRenderSession } from './render-runtime.js'; +import { mountSplitPane } from './split-pane.js'; +import { mountWorkspaceFullscreenMode } from './workspace-fullscreen.js'; + +const CODE_QUERY_PARAM = 'code'; +const PRESET_QUERY_PARAM = 'example'; +const URL_SYNC_DELAY = 250; +const DEFAULT_EDITOR_WIDTH_PERCENT = 48; +const EDITOR_COLLAPSE_THRESHOLD_PX = 260; +const EDITOR_MIN_WIDTH_PX = 360; +const EDITOR_SIDE_MIN_RATIO = 0.42; +const INSPECTOR_REFRESH_INTERVAL_MS = 100; +const DEFAULT_REFRESH_RATE = 60; +const FPS_GRAPH_HEADROOM = 1.5; + +type MonacoModule = typeof import('monaco-editor/esm/vs/editor/editor.api.js'); +type MonacoTextModel = ReturnType; +interface RenderStats { + drawCalls: number; + objects: number; +} + +interface PlaygroundRendererOptions { + antialiasing?: boolean; + pixelRatio?: number; +} + +interface PlaygroundPreset { + slug: string; + title: string; + tags: string[]; + code: string; + accent: string; + renderer: PlaygroundRendererOptions; +} + +interface PlaygroundConfig { + presets: PlaygroundPreset[]; + labels: { + ready: string; + error: string; + }; + common: { + run: string; + preset: string; + }; + typeDefinitions: Array<{ + path: string; + content: string; + }>; +} + +interface TypeScriptContribution { + ModuleKind: { + ESNext: number; + }; + ModuleResolutionKind: { + NodeJs: number; + }; + ScriptTarget: { + ES2020: number; + }; + typescriptDefaults: { + addExtraLib(content: string, filePath?: string): unknown; + setCompilerOptions(options: Record): void; + setDiagnosticsOptions(options: Record): void; + }; +} + +interface MonacoRuntime { + monaco: MonacoModule; + typescript: TypeScriptContribution; +} + +interface EditorController { + getValue(): string; + setPreset(preset: PlaygroundPreset, code?: string): void; + onChange(callback: (code: string) => void): void; + layout(): void; +} + +const detectRefreshRate = (function () { + function refreshRate() { + let frameCount = 0; + let startTime = 0; + return new Promise(resolve => { + function estimateRefreshRate(currentTime: number) { + frameCount++; + const elapsedTime = currentTime - startTime; + if (elapsedTime >= 1000) { + resolve(normalizeRefreshRate(Math.round((frameCount * 1000) / elapsedTime))); + return; + } + requestAnimationFrame(estimateRefreshRate); + } + + requestAnimationFrame(t => { + startTime = t; + frameCount = -1; + estimateRefreshRate(t); + }); + }); + } + let cached: number | undefined = undefined; + + return async function () { + if (cached == null) { + cached = await refreshRate(); + } + return cached; + }; +})(); + +let monacoPromise: Promise | undefined; + +export async function mountPlayground(root: HTMLElement, config: PlaygroundConfig) { + const editorHost = query(root, '[data-editor-host]'); + const loading = query(root, '[data-editor-loading]'); + const canvas = query(root, '[data-render-canvas]'); + const presetMenu = query(root, '[data-preset-menu]'); + const presetTrigger = query(root, '[data-preset-trigger]'); + const currentPresetLabel = query(root, '[data-current-preset]'); + const presetList = query(root, '[data-preset-list]'); + const status = query(root, '[data-status]'); + const runButton = query(root, '[data-run]'); + const workspace = query(root, '[data-workspace]'); + const splitter = query(root, '[data-workspace-splitter]'); + const inspector = query(root, '[data-inspector]'); + const configPanel = root.querySelector('[data-config-panel]'); + const previewStatus = root.querySelector('[data-preview-status]'); + + const initialParams = new URLSearchParams(window.location.search); + const requestedPreset = initialParams.get(PRESET_QUERY_PARAM); + const initialCode = readCodeFromUrl(initialParams); + let currentPreset = config.presets.find(preset => preset.slug === requestedPreset) ?? config.presets[0]; + let isApplyingEditorValue = false; + let urlSyncTimer: number | undefined; + let runId = 0; + let activeSession: RenderSession | undefined; + let runAbortController: AbortController | undefined; + const presetButtons = new Map(); + const inspectorPane = await setupInspectorPane(inspector); + + setStatus('loading', 'Loading editor'); + + for (const preset of config.presets) { + const button = createPresetButton(preset); + button.addEventListener('click', () => { + applyPreset(preset, { syncUrl: true }); + setPresetMenuOpen(false); + presetTrigger.focus(); + }); + presetButtons.set(preset.slug, button); + presetList.append(button); + } + + const monacoRuntime = await loadMonaco(); + configureTypeScript(monacoRuntime.typescript, config); + const editor = createCodeEditor(monacoRuntime.monaco, editorHost, config); + const fullscreenMode = mountWorkspaceFullscreenMode({ + onChange() { + handleViewportChange(); + }, + }); + let disposed = false; + loading.remove(); + + function applyPreset(preset: PlaygroundPreset, options: { code?: string; syncUrl?: boolean } = {}) { + currentPreset = preset; + updatePresetMenu(preset); + applyEditorValue(() => editor.setPreset(preset, options.code)); + + if (options.syncUrl) { + if (urlSyncTimer !== undefined) { + window.clearTimeout(urlSyncTimer); + urlSyncTimer = undefined; + } + + syncPresetUrl(preset); + } + + run(); + } + + function applyEditorValue(callback: () => void) { + isApplyingEditorValue = true; + callback(); + window.setTimeout(() => { + isApplyingEditorValue = false; + }, 0); + } + + async function run() { + const nextRunId = runId + 1; + runId = nextRunId; + runAbortController?.abort(); + const abortController = new AbortController(); + runAbortController = abortController; + activeSession?.dispose(); + activeSession = undefined; + inspectorPane.setError(''); + setStatus('loading', config.common.run); + setPreviewStatus('loading', previewStatus?.dataset.loadingLabel ?? config.common.run); + + try { + const session = await createRenderSession(canvas, editor.getValue(), currentPreset.accent, { + configPanel: configPanel + ? { + container: configPanel, + title: configPanel.dataset.configPanelTitle ?? 'Config', + } + : undefined, + signal: abortController.signal, + renderer: currentPreset.renderer, + onStats(stats) { + if (nextRunId === runId) { + updateStats(stats); + } + }, + beginFrame() { + inspectorPane.beginFrame(); + }, + endFrame() { + inspectorPane.endFrame(); + }, + onStatus(nextStatus) { + if (nextRunId !== runId) { + return; + } + + const label = + nextStatus.label ?? (nextStatus.state === 'loading' ? config.common.run : config.labels.ready); + setStatus(nextStatus.state, label); + setPreviewStatus( + nextStatus.state, + nextStatus.state === 'loading' + ? (nextStatus.label ?? previewStatus?.dataset.loadingLabel) + : undefined, + ); + }, + }); + + if (nextRunId !== runId) { + session.dispose(); + return; + } + + activeSession = session; + if (runAbortController === abortController) { + runAbortController = undefined; + } + updateStats(session.stats); + } catch (caught) { + if (nextRunId !== runId) { + return; + } + + if (runAbortController === abortController) { + runAbortController = undefined; + } + const message = caught instanceof Error ? caught.message : String(caught); + inspectorPane.setError(message); + inspectorPane.expand(); + setPreviewStatus('ready'); + setStatus('error', config.labels.error); + } + } + + presetTrigger.addEventListener('click', () => { + setPresetMenuOpen(presetList.hidden as any as boolean); + }); + presetTrigger.addEventListener('keydown', event => { + if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setPresetMenuOpen(true); + } + }); + presetList.addEventListener('keydown', handlePresetListKeydown); + document.addEventListener('pointerdown', event => { + if (event.target instanceof Node && !presetMenu.contains(event.target)) { + setPresetMenuOpen(false); + } + }); + + editor.onChange(code => { + if (isApplyingEditorValue) { + return; + } + + scheduleCodeUrlSync(code, currentPreset); + }); + + runButton.addEventListener('click', run); + + const splitPane = mountSplitPane({ + container: workspace, + splitter, + collapsedDatasetKey: 'paneCollapsed', + cssProperty: '--editor-width', + defaultValue: DEFAULT_EDITOR_WIDTH_PERCENT, + valueUnit: '%', + keyboardStep: 4, + clampValue(value, rect) { + if (rect.width <= 0) { + return value; + } + + const requestedWidth = (value / 100) * rect.width; + const minimum = Math.min(EDITOR_MIN_WIDTH_PX, rect.width * EDITOR_SIDE_MIN_RATIO); + const maximum = rect.width - minimum; + const width = Math.min(Math.max(requestedWidth, minimum), maximum); + + return (width / rect.width) * 100; + }, + shouldCollapse(value, rect) { + return (value / 100) * rect.width <= Math.min(EDITOR_COLLAPSE_THRESHOLD_PX, rect.width * 0.28); + }, + pointerToValue(event, rect) { + return rect.width > 0 ? ((event.clientX - rect.left) / rect.width) * 100 : DEFAULT_EDITOR_WIDTH_PERCENT; + }, + onChange() { + editor.layout(); + resizePreview(); + }, + }); + window.addEventListener('resize', handleViewportChange); + window.addEventListener('beforeunload', dispose, { once: true }); + document.addEventListener('astro:before-swap', dispose, { once: true }); + + applyPreset(currentPreset, { code: initialCode }); + + function handleViewportChange() { + setPresetMenuOpen(false); + editor.layout(); + resizePreview(); + } + + function dispose() { + if (disposed) { + return; + } + + disposed = true; + runAbortController?.abort(); + activeSession?.dispose(); + inspectorPane.dispose(); + splitPane.dispose(); + fullscreenMode.dispose(); + window.removeEventListener('resize', handleViewportChange); + window.removeEventListener('beforeunload', dispose); + document.removeEventListener('astro:before-swap', dispose); + } + + function scheduleCodeUrlSync(code: string, preset: PlaygroundPreset) { + if (urlSyncTimer !== undefined) { + window.clearTimeout(urlSyncTimer); + } + + urlSyncTimer = window.setTimeout(() => { + syncCodeUrl(code, preset); + urlSyncTimer = undefined; + }, URL_SYNC_DELAY); + } + + function setStatus(state: 'loading' | 'ready' | 'error', label: string) { + status.dataset.state = state; + status.setAttribute('aria-label', `${config.common.run}: ${label}`); + status.title = label; + } + + function setPreviewStatus(state: 'loading' | 'ready', label?: string) { + if (!previewStatus) { + return; + } + + if (state === 'ready') { + previewStatus.hidden = true; + previewStatus.textContent = ''; + return; + } + + previewStatus.hidden = false; + previewStatus.textContent = label ?? previewStatus.dataset.loadingLabel ?? config.common.run; + } + + function updateStats(stats: RenderStats) { + inspectorPane.updateStats(stats); + } + + function resizePreview() { + activeSession?.resize(); + } + + function createPresetButton(preset: PlaygroundPreset) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'preset-option'; + button.dataset.presetOption = preset.slug; + button.setAttribute('role', 'menuitemradio'); + button.setAttribute('aria-checked', 'false'); + button.style.setProperty('--preset-accent', preset.accent); + + const marker = document.createElement('span'); + marker.className = 'preset-option-marker'; + marker.setAttribute('aria-hidden', 'true'); + + const content = document.createElement('span'); + content.className = 'preset-option-content'; + + const title = document.createElement('strong'); + title.textContent = preset.title; + + const tags = document.createElement('span'); + tags.className = 'preset-option-tags'; + tags.textContent = preset.tags.join(' / '); + + content.append(title, tags); + button.append(marker, content); + + return button; + } + + function updatePresetMenu(preset: PlaygroundPreset) { + currentPresetLabel.textContent = preset.title; + currentPresetLabel.style.setProperty('--preset-accent', preset.accent); + + for (const [slug, button] of presetButtons) { + const isActive = slug === preset.slug; + button.setAttribute('aria-checked', String(isActive)); + if (isActive) { + button.dataset.active = 'true'; + } else { + delete button.dataset.active; + } + } + } + + function setPresetMenuOpen(open: boolean) { + presetList.hidden = !open; + presetTrigger.setAttribute('aria-expanded', String(open)); + + if (open) { + const activeButton = + presetButtons.get(currentPreset.slug) ?? presetList.querySelector('button'); + activeButton?.focus({ preventScroll: true }); + } + } + + function handlePresetListKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault(); + setPresetMenuOpen(false); + presetTrigger.focus(); + return; + } + + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Home' && event.key !== 'End') { + return; + } + + event.preventDefault(); + const buttons = Array.from(presetButtons.values()); + const currentIndex = buttons.findIndex(button => button === document.activeElement); + + if (event.key === 'Home') { + buttons[0]?.focus(); + return; + } + + if (event.key === 'End') { + buttons[buttons.length - 1]?.focus(); + return; + } + + const direction = event.key === 'ArrowDown' ? 1 : -1; + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + buttons.length) % buttons.length; + buttons[nextIndex]?.focus(); + } +} + +async function loadMonaco() { + if (!monacoPromise) { + monacoPromise = (async () => { + const [{ default: EditorWorker }, { default: TypeScriptWorker }] = await Promise.all([ + import('monaco-editor/esm/vs/editor/editor.worker.js?worker&inline'), + import('monaco-editor/esm/vs/language/typescript/ts.worker.js?worker&inline'), + ]); + + (self as unknown as { MonacoEnvironment: unknown }).MonacoEnvironment = { + getWorker(_workerId: string, label: string) { + if (label === 'typescript' || label === 'javascript') { + return new TypeScriptWorker(); + } + return new EditorWorker(); + }, + }; + + const monaco = await import('monaco-editor/esm/vs/editor/editor.api.js'); + await import('monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution.js'); + const typescript = + (await import('monaco-editor/esm/vs/language/typescript/monaco.contribution.js')) as unknown as TypeScriptContribution; + + return { + monaco, + typescript, + }; + })(); + } + + return monacoPromise; +} + +function configureTypeScript(typescript: TypeScriptContribution, config: PlaygroundConfig) { + const defaults = typescript.typescriptDefaults; + + defaults.setDiagnosticsOptions({ + noSemanticValidation: false, + noSyntaxValidation: false, + noSuggestionDiagnostics: false, + }); + defaults.setCompilerOptions({ + allowNonTsExtensions: true, + module: typescript.ModuleKind.ESNext, + moduleResolution: typescript.ModuleResolutionKind.NodeJs, + noEmit: true, + strict: true, + target: typescript.ScriptTarget.ES2020, + lib: ['es2020', 'dom'], + }); + for (const definition of config.typeDefinitions) { + defaults.addExtraLib(definition.content, definition.path); + } +} + +function createCodeEditor(monaco: MonacoModule, host: HTMLElement, config: PlaygroundConfig): EditorController { + const models = new Map(); + const editor = monaco.editor.create(host, { + automaticLayout: false, + contextmenu: false, + fixedOverflowWidgets: true, + fontFamily: 'SFMono-Regular, Consolas, Liberation Mono, monospace', + fontSize: 13, + lineNumbers: 'on', + minimap: { enabled: false }, + overviewRulerLanes: 0, + padding: { top: 14, bottom: 92 }, + renderLineHighlight: 'line', + scrollBeyondLastLine: false, + scrollbar: { + horizontalScrollbarSize: 10, + verticalScrollbarSize: 10, + }, + tabSize: 2, + theme: document.documentElement.dataset.theme === 'dark' ? 'vs-dark' : 'vs', + wordWrap: 'off', + }); + + for (const preset of config.presets) { + models.set(preset.slug, createModel(monaco, preset)); + } + + const themeObserver = new MutationObserver(() => { + monaco.editor.setTheme(document.documentElement.dataset.theme === 'dark' ? 'vs-dark' : 'vs'); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }); + + return { + getValue() { + return editor.getValue(); + }, + setPreset(preset, code = preset.code) { + const model = models.get(preset.slug) ?? createModel(monaco, preset); + model.setValue(code); + models.set(preset.slug, model); + editor.setModel(model); + editor.focus(); + editor.layout(); + }, + onChange(callback) { + editor.onDidChangeModelContent(() => callback(editor.getValue())); + }, + layout() { + editor.layout(); + }, + }; +} + +function createModel(monaco: MonacoModule, preset: PlaygroundPreset) { + const uri = monaco.Uri.parse(`file:///src/content/examples/${preset.slug}.ts`); + const existing = monaco.editor.getModel(uri); + + if (existing) { + existing.setValue(preset.code); + return existing; + } + + return monaco.editor.createModel(preset.code, 'typescript', uri); +} + +async function setupInspectorPane(container: HTMLElement) { + const params = { + fps: DEFAULT_REFRESH_RATE, + drawCalls: 0, + objects: 0, + error: '', + }; + const pane = new Pane({ + container, + expanded: true, + title: container.dataset.inspectorTitle ?? 'Inspector', + }); + + pane.registerPlugin(TweakpaneEssentialsPlugin); + + let pendingStats: RenderStats | undefined; + let refreshTimer: number | undefined; + let lastRefreshTime = 0; + + const fpsGraph = pane.addBlade({ + view: 'fpsgraph', + label: 'FPS', + max: (await detectRefreshRate()) * FPS_GRAPH_HEADROOM, + rows: 2, + }); + pane.addBinding(params, 'drawCalls', { + interval: INSPECTOR_REFRESH_INTERVAL_MS, + label: container.dataset.drawCallsLabel ?? 'Draw calls', + format: v => v.toString(), + readonly: true, + }); + pane.addBinding(params, 'objects', { + interval: INSPECTOR_REFRESH_INTERVAL_MS, + label: container.dataset.objectsLabel ?? 'Objects', + format: v => v.toString(), + readonly: true, + }); + const errorBinding = pane.addBinding(params, 'error', { + label: container.dataset.errorLabel ?? 'Error', + multiline: true, + readonly: true, + rows: 3, + }); + errorBinding.hidden = true; + + function scheduleRefresh() { + if (refreshTimer !== undefined) { + return; + } + + const now = performance.now(); + const delay = Math.max(0, INSPECTOR_REFRESH_INTERVAL_MS - (now - lastRefreshTime)); + refreshTimer = window.setTimeout(() => { + refreshTimer = undefined; + lastRefreshTime = performance.now(); + applyPendingStats(); + pane.refresh(); + }, delay); + } + + function applyPendingStats() { + if (!pendingStats) { + return; + } + + const stats = pendingStats; + pendingStats = undefined; + + params.drawCalls = stats.drawCalls; + params.objects = stats.objects; + } + + return { + dispose() { + if (refreshTimer !== undefined) { + window.clearTimeout(refreshTimer); + refreshTimer = undefined; + } + + pane.dispose(); + }, + expand() { + pane.expanded = true; + }, + setError(message: string) { + params.error = message; + errorBinding.hidden = message.length === 0; + scheduleRefresh(); + }, + beginFrame() { + (fpsGraph as any).begin(); + }, + endFrame() { + (fpsGraph as any).end(); + }, + updateStats(stats: RenderStats) { + pendingStats = stats; + scheduleRefresh(); + }, + }; +} + +function normalizeRefreshRate(value: number) { + return Number.isFinite(value) && value > 0 ? value : DEFAULT_REFRESH_RATE; +} + +function readCodeFromUrl(params: URLSearchParams) { + const encodedCode = params.get(CODE_QUERY_PARAM); + + if (!encodedCode) { + return undefined; + } + + try { + return decompressFromEncodedURIComponent(encodedCode) ?? undefined; + } catch { + return undefined; + } +} + +function syncPresetUrl(preset: PlaygroundPreset) { + const url = new URL(window.location.href); + url.searchParams.set(PRESET_QUERY_PARAM, preset.slug); + url.searchParams.delete(CODE_QUERY_PARAM); + window.history.replaceState({}, '', url); +} + +function syncCodeUrl(code: string, preset: PlaygroundPreset) { + const url = new URL(window.location.href); + url.searchParams.set(PRESET_QUERY_PARAM, preset.slug); + url.searchParams.set(CODE_QUERY_PARAM, compressToEncodedURIComponent(code)); + window.history.replaceState({}, '', url); +} + +function query(root: HTMLElement, selector: string): T { + const element = root.querySelector(selector); + + if (!element) { + throw new Error(`Missing playground element: ${selector}`); + } + + return element; +} diff --git a/website/src/client/render-runtime.d.ts b/website/src/client/render-runtime.d.ts new file mode 100644 index 0000000..288b0af --- /dev/null +++ b/website/src/client/render-runtime.d.ts @@ -0,0 +1,55 @@ +import type { Scene3D, Viewer } from '@manycore/aholo-viewer'; +import type { Pane } from 'tweakpane'; +import type { CameraControl } from './camera-control.js'; + +export interface RuntimeRenderer { + readonly viewer: Viewer; + readonly scene: Scene3D; + frame(callback: (state: { time: number; delta: number }) => boolean): void; + render(): void; + resize(): void; +} + +export interface RuntimeLoadingController { + show(label?: string): void; + hide(): void; +} + +interface RuntimeConfigPaneOptions { + expanded?: boolean; + title?: string; +} + +export interface RuntimeConfigPanel { + readonly available: boolean; + readonly container: HTMLElement; + createPane(options?: RuntimeConfigPaneOptions): Pane; + clear(): void; + hide(): void; + show(): void; +} + +interface RuntimeIndexedDBSetOptions { + version?: number; +} + +interface RuntimeIndexedDBGetOptions { + version?: number; +} + +export interface RuntimeIndexedDBStorage { + readonly available: boolean; + get(key: string, options?: RuntimeIndexedDBGetOptions): Promise; + set(key: string, value: T, options?: RuntimeIndexedDBSetOptions): Promise; + delete(key: string): Promise; + clear(): Promise; +} + +export interface RenderRuntime { + renderer: RuntimeRenderer; + control: CameraControl; + loading: RuntimeLoadingController; + configPanel: RuntimeConfigPanel; + indexedDB: RuntimeIndexedDBStorage; + signal: AbortSignal; +} diff --git a/website/src/client/render-runtime.ts b/website/src/client/render-runtime.ts new file mode 100644 index 0000000..3300695 --- /dev/null +++ b/website/src/client/render-runtime.ts @@ -0,0 +1,941 @@ +import * as RendererApi from '@manycore/aholo-viewer'; +import * as TweakpaneApi from 'tweakpane'; +import type { Camera3D, Scene3D, Viewer } from '@manycore/aholo-viewer'; +import type { Pane } from 'tweakpane'; +import type { Diagnostic } from 'typescript'; +import { CameraControl } from './camera-control.js'; +import { + abortable as abortableWithMessage, + countSceneObjects, + createAbortError as createAbortErrorWithMessage, + syncCameraAspect, + throwIfAborted as throwIfAbortedWithMessage, +} from './rendering.js'; + +export interface RenderStats { + drawCalls: number; + objects: number; +} + +export interface RenderSession { + stats: RenderStats; + dispose(): void; + resize(): void; + setAccent(accent: string): void; + setPaused(paused: boolean): void; +} + +export interface RenderSessionStatus { + state: 'loading' | 'ready'; + label?: string; +} + +export interface RenderSessionOptions { + configPanel?: RenderSessionConfigPanelOptions; + refreshRate?: number; + onStats?: (stats: RenderStats) => void; + onStatus?: (status: RenderSessionStatus) => void; + beginFrame?: () => void; + endFrame?: () => void; + renderer?: RenderSessionRendererOptions; + signal?: AbortSignal; +} + +export interface RenderSessionConfigPanelOptions { + container?: HTMLElement | null; + title?: string; +} + +export interface RenderSessionRendererOptions { + antialiasing?: boolean; + pixelRatio?: number; +} + +export interface RuntimeRenderer { + readonly viewer: Viewer; + readonly scene: Scene3D; + frame(callback: (state: { time: number; delta: number }) => boolean): void; + render(): void; + resize(): void; +} + +export interface RuntimeLoadingController { + show(label?: string): void; + hide(): void; +} + +interface RuntimeConfigPaneOptions { + expanded?: boolean; + title?: string; +} + +export interface RuntimeConfigPanel { + readonly available: boolean; + readonly container: HTMLElement; + createPane(options?: RuntimeConfigPaneOptions): Pane; + clear(): void; + hide(): void; + show(): void; +} + +interface RuntimeIndexedDBSetOptions { + version?: number; +} + +interface RuntimeIndexedDBGetOptions { + version?: number; +} + +export interface RuntimeIndexedDBStorage { + readonly available: boolean; + get(key: string, options?: RuntimeIndexedDBGetOptions): Promise; + set(key: string, value: T, options?: RuntimeIndexedDBSetOptions): Promise; + delete(key: string): Promise; + clear(): Promise; +} + +export interface RenderRuntime { + renderer: RuntimeRenderer; + control: CameraControl; + loading: RuntimeLoadingController; + configPanel: RuntimeConfigPanel; + indexedDB: RuntimeIndexedDBStorage; + signal: AbortSignal; +} + +type TypeScriptModule = typeof import('typescript'); +type RenderRuntimeCleanup = () => void; +type RenderRuntimeEntry = ( + runtime: RenderRuntime, +) => void | RenderRuntimeCleanup | Promise; +type FrameCallback = (state: { time: number; delta: number }) => boolean; +type RuntimeRendererApi = typeof RendererApi; +type RuntimeTweakpaneApi = typeof TweakpaneApi; +type RenderSessionOptionsInput = RenderSessionOptions | ((stats: RenderStats) => void); +type TweakpanePane = InstanceType; +type VersionedCacheOptions = { + version?: number; +}; +type IndexedDBCacheRecord = { + key: string; + value: T; + version?: number; +}; + +interface RuntimeGlobal { + __AHOLO_RENDER_RUNTIME_API__?: RuntimeRendererApi; + __AHOLO_TWEAKPANE_RUNTIME_API__?: RuntimeTweakpaneApi; +} + +const RENDER_RUNTIME_DB_NAME = 'aholo-render-runtime'; +const RENDER_RUNTIME_CACHE_STORE_NAME = 'runtime-cache'; +const RENDER_RUNTIME_ABORT_MESSAGE = 'Render runtime was aborted.'; + +let renderSessionId = 0; +const renderSurfaces = new WeakMap(); + +export async function createRenderSession( + target: HTMLElement, + code: string, + accent: string, + optionsInput?: RenderSessionOptionsInput, +): Promise { + const options = normalizeRenderSessionOptions(optionsInput); + const abortController = new AbortController(); + const unlinkAbortSignal = linkAbortSignal(options.signal, abortController); + const signal = abortController.signal; + const loading = createRuntimeLoadingController(options.onStatus); + const configPanel = createRuntimeConfigPanel(options.configPanel); + const indexedDB = createRuntimeIndexedDBStorage(signal); + let renderer: RenderSessionRenderer | undefined; + let cleanup: RenderRuntimeCleanup | undefined; + + try { + loading.startInitial(); + + const entry = await abortable(compileRenderRuntimeModule(code), signal); + throwIfAborted(signal); + const surface = prepareRenderSurface(target, accent); + renderer = new RenderSessionRenderer( + surface, + options.onStats, + options.beginFrame, + options.endFrame, + options.renderer, + ); + renderer.start(); + throwIfAborted(signal); + const result = await abortable( + Promise.resolve( + entry({ + renderer, + control: renderer.control, + loading: loading.controller, + configPanel: configPanel.controller, + indexedDB, + signal, + }), + ), + signal, + ); + + cleanup = typeof result === 'function' ? result : undefined; + throwIfAborted(signal); + loading.finishInitial(); + + return { + stats: renderer.stats, + dispose() { + loading.dispose(); + configPanel.dispose(); + abortController.abort(); + unlinkAbortSignal(); + try { + cleanup?.(); + } finally { + renderer?.dispose(); + } + }, + resize() { + renderer?.resize(); + }, + setAccent(nextAccent: string) { + surface.style.setProperty('--runtime-accent', nextAccent); + }, + setPaused(paused: boolean) { + if (paused) { + renderer?.pause(); + } else { + renderer?.start(); + } + }, + }; + } catch (error) { + loading.dispose(); + configPanel.dispose(); + abortController.abort(); + unlinkAbortSignal(); + try { + cleanup?.(); + } finally { + renderer?.dispose(); + } + throw error; + } +} + +function normalizeRenderSessionOptions(options: RenderSessionOptionsInput | undefined): RenderSessionOptions { + if (typeof options === 'function') { + return { + onStats: options, + }; + } + + return options ?? {}; +} + +function linkAbortSignal(source: AbortSignal | undefined, target: AbortController) { + if (!source) { + return () => {}; + } + + if (source.aborted) { + target.abort(); + return () => {}; + } + + const abort = () => { + target.abort(); + }; + source.addEventListener('abort', abort, { once: true }); + + return () => { + source.removeEventListener('abort', abort); + }; +} + +function createRuntimeIndexedDBStorage(signal: AbortSignal): RuntimeIndexedDBStorage { + const available = 'indexedDB' in globalThis; + + return { + available, + async get(key: string, options?: VersionedCacheOptions) { + if (!available) { + return undefined; + } + + const record = await readIndexedDBRecord(key, signal); + + if (!record || !versionsMatch(record.version, options?.version)) { + return undefined; + } + + return record.value; + }, + async set(key: string, value: T, options?: VersionedCacheOptions) { + if (!available) { + return; + } + + await writeIndexedDBRecord( + { + key, + version: options?.version ?? 0, + value, + }, + signal, + ); + }, + async delete(key: string) { + if (!available) { + return; + } + + await deleteIndexedDBRecord(key, signal); + }, + async clear() { + if (!available) { + return; + } + + await clearIndexedDBStore(signal); + }, + }; +} + +function createRuntimeLoadingController(onStatus: RenderSessionOptions['onStatus']) { + let disposed = false; + let manualLoading = false; + + function notify(state: RenderSessionStatus['state'], label?: string) { + if (!disposed) { + onStatus?.({ state, label }); + } + } + + const controller: RuntimeLoadingController = { + show(label?: string) { + manualLoading = true; + notify('loading', label); + }, + hide() { + manualLoading = false; + notify('ready'); + }, + }; + + return { + controller, + dispose() { + disposed = true; + }, + finishInitial() { + if (!manualLoading) { + notify('ready'); + } + }, + startInitial() { + notify('loading'); + }, + }; +} + +function createRuntimeConfigPanel(options: RenderSessionConfigPanelOptions | undefined) { + const connectedContainer = options?.container ?? undefined; + const container = connectedContainer ?? document.createElement('div'); + const defaultTitle = options?.title ?? container.dataset.configPanelTitle ?? 'Config'; + let pane: TweakpanePane | undefined; + + hide(); + + function show() { + container.hidden = false; + container.dataset.state = 'active'; + } + + function hide() { + container.hidden = true; + delete container.dataset.state; + } + + function clear() { + pane?.dispose(); + pane = undefined; + container.replaceChildren(); + } + + const controller: RuntimeConfigPanel = { + available: connectedContainer !== undefined, + get container() { + show(); + return container; + }, + createPane(paneOptions: { expanded?: boolean; title?: string } = {}) { + clear(); + show(); + pane = new TweakpaneApi.Pane({ + container, + expanded: paneOptions.expanded ?? getDefaultConfigPaneExpanded(), + title: paneOptions.title ?? defaultTitle, + }); + return pane; + }, + clear() { + clear(); + }, + hide() { + hide(); + }, + show() { + show(); + }, + }; + + return { + controller, + dispose() { + clear(); + hide(); + }, + }; +} + +function getDefaultConfigPaneExpanded() { + return !window.matchMedia('(max-width: 900px)').matches; +} + +function throwIfAborted(signal: AbortSignal) { + throwIfAbortedWithMessage(signal, RENDER_RUNTIME_ABORT_MESSAGE); +} + +function createAbortError() { + return createAbortErrorWithMessage(RENDER_RUNTIME_ABORT_MESSAGE); +} + +function abortable(promise: Promise, signal: AbortSignal) { + return abortableWithMessage(promise, signal, RENDER_RUNTIME_ABORT_MESSAGE); +} + +function versionsMatch(recordVersion: number | string | undefined, requestedVersion: number | string | undefined) { + return requestedVersion === undefined || recordVersion === requestedVersion; +} + +async function readIndexedDBRecord(key: string, signal: AbortSignal): Promise | undefined> { + const database = await openRuntimeIndexedDB(signal); + + try { + const transaction = database.transaction(RENDER_RUNTIME_CACHE_STORE_NAME, 'readonly'); + const done = waitForIndexedDBTransaction(transaction, signal); + const request = transaction.objectStore(RENDER_RUNTIME_CACHE_STORE_NAME).get(key); + const record = await indexedDBRequestToPromise | undefined>(request, signal); + await done; + + return record; + } finally { + database.close(); + } +} + +async function writeIndexedDBRecord(record: IndexedDBCacheRecord, signal: AbortSignal) { + const database = await openRuntimeIndexedDB(signal); + + try { + const transaction = database.transaction(RENDER_RUNTIME_CACHE_STORE_NAME, 'readwrite'); + const done = waitForIndexedDBTransaction(transaction, signal); + transaction.objectStore(RENDER_RUNTIME_CACHE_STORE_NAME).put(record); + await done; + } finally { + database.close(); + } +} + +async function deleteIndexedDBRecord(key: string, signal: AbortSignal) { + const database = await openRuntimeIndexedDB(signal); + + try { + const transaction = database.transaction(RENDER_RUNTIME_CACHE_STORE_NAME, 'readwrite'); + const done = waitForIndexedDBTransaction(transaction, signal); + transaction.objectStore(RENDER_RUNTIME_CACHE_STORE_NAME).delete(key); + await done; + } finally { + database.close(); + } +} + +async function clearIndexedDBStore(signal: AbortSignal) { + const database = await openRuntimeIndexedDB(signal); + + try { + const transaction = database.transaction(RENDER_RUNTIME_CACHE_STORE_NAME, 'readwrite'); + const done = waitForIndexedDBTransaction(transaction, signal); + transaction.objectStore(RENDER_RUNTIME_CACHE_STORE_NAME).clear(); + await done; + } finally { + database.close(); + } +} + +function openRuntimeIndexedDB( + signal: AbortSignal, + version?: number, + schemaRepairAttempted = false, +): Promise { + if (signal.aborted) { + return Promise.reject(createAbortError()); + } + + return new Promise((resolve, reject) => { + const request = + version === undefined + ? indexedDB.open(RENDER_RUNTIME_DB_NAME) + : indexedDB.open(RENDER_RUNTIME_DB_NAME, version); + const rejectAbort = () => { + reject(createAbortError()); + }; + + signal.addEventListener('abort', rejectAbort, { once: true }); + + request.onupgradeneeded = () => { + const database = request.result; + + if (!database.objectStoreNames.contains(RENDER_RUNTIME_CACHE_STORE_NAME)) { + database.createObjectStore(RENDER_RUNTIME_CACHE_STORE_NAME, { keyPath: 'key' }); + } + }; + request.onsuccess = () => { + signal.removeEventListener('abort', rejectAbort); + const database = request.result; + + if (signal.aborted) { + database.close(); + reject(createAbortError()); + return; + } + + if (!database.objectStoreNames.contains(RENDER_RUNTIME_CACHE_STORE_NAME)) { + if (schemaRepairAttempted) { + database.close(); + reject(new Error(`IndexedDB store "${RENDER_RUNTIME_CACHE_STORE_NAME}" could not be created.`)); + return; + } + + const nextVersion = database.version + 1; + database.close(); + openRuntimeIndexedDB(signal, nextVersion, true).then(resolve, reject); + return; + } + + resolve(database); + }; + request.onerror = () => { + signal.removeEventListener('abort', rejectAbort); + reject(request.error ?? new Error('IndexedDB open failed.')); + }; + request.onblocked = () => { + signal.removeEventListener('abort', rejectAbort); + reject(new Error('IndexedDB upgrade is blocked by another open tab.')); + }; + }); +} + +function indexedDBRequestToPromise(request: IDBRequest, signal: AbortSignal): Promise { + throwIfAborted(signal); + + return new Promise((resolve, reject) => { + const rejectAbort = () => { + reject(createAbortError()); + }; + + signal.addEventListener('abort', rejectAbort, { once: true }); + + request.onsuccess = () => { + signal.removeEventListener('abort', rejectAbort); + resolve(request.result); + }; + request.onerror = () => { + signal.removeEventListener('abort', rejectAbort); + reject(request.error ?? new Error('IndexedDB request failed.')); + }; + }); +} + +function waitForIndexedDBTransaction(transaction: IDBTransaction, signal: AbortSignal): Promise { + throwIfAborted(signal); + + return new Promise((resolve, reject) => { + const cleanup = () => { + signal.removeEventListener('abort', rejectAbort); + }; + const rejectAbort = () => { + try { + transaction.abort(); + } catch { + // The transaction may have already completed. + } + + reject(createAbortError()); + }; + + signal.addEventListener('abort', rejectAbort, { once: true }); + + transaction.oncomplete = () => { + cleanup(); + resolve(); + }; + transaction.onerror = () => { + cleanup(); + reject(transaction.error ?? new Error('IndexedDB transaction failed.')); + }; + transaction.onabort = () => { + cleanup(); + reject(transaction.error ?? createAbortError()); + }; + }); +} + +async function compileRenderRuntimeModule(code: string): Promise { + const ts = await import('typescript'); + const output = transpileRenderRuntimeCode(ts, code); + const module = await importModule(output); + const entry = module.default; + + if (typeof entry !== 'function') { + throw new Error('Playground code must export a default function.'); + } + + return entry as RenderRuntimeEntry; +} + +function transpileRenderRuntimeCode(ts: TypeScriptModule, code: string) { + const result = ts.transpileModule(code, { + compilerOptions: { + allowJs: true, + esModuleInterop: true, + module: ts.ModuleKind.ESNext, + strict: true, + target: ts.ScriptTarget.ES2020, + }, + reportDiagnostics: true, + }); + + const diagnostic = result.diagnostics?.find(item => item.category === ts.DiagnosticCategory.Error); + if (diagnostic) { + throw new Error(formatDiagnostic(ts, diagnostic)); + } + + const outputText = replaceRuntimeImports(result.outputText); + + if (/^\s*import\s/m.test(outputText)) { + throw new Error('Playground code can only import runtime objects from @manycore/aholo-viewer and tweakpane.'); + } + + return `${outputText}\n//# sourceURL=aholo-render-runtime.js`; +} + +function replaceRuntimeImports(source: string) { + return source + .replace( + /^\s*import\s+\{([\s\S]*?)\}\s+from\s+["']@manycore\/aholo-viewer["'];?\s*$/gm, + (_match, specifiers: string) => { + const bindings = specifiers + .split(',') + .map(specifier => specifier.trim()) + .filter(Boolean) + .map(specifier => specifier.replace(/\s+as\s+/u, ': ')) + .join(', '); + + return `const { ${bindings} } = globalThis.__AHOLO_RENDER_RUNTIME_API__;`; + }, + ) + .replace( + /^\s*import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+["']@manycore\/aholo-viewer["'];?\s*$/gm, + 'const $1 = globalThis.__AHOLO_RENDER_RUNTIME_API__;', + ) + .replace(/^\s*import\s+\{([\s\S]*?)\}\s+from\s+["']tweakpane["'];?\s*$/gm, (_match, specifiers: string) => { + const bindings = specifiers + .split(',') + .map(specifier => specifier.trim()) + .filter(Boolean) + .map(specifier => specifier.replace(/\s+as\s+/u, ': ')) + .join(', '); + + return `const { ${bindings} } = globalThis.__AHOLO_TWEAKPANE_RUNTIME_API__;`; + }) + .replace( + /^\s*import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+["']tweakpane["'];?\s*$/gm, + 'const $1 = globalThis.__AHOLO_TWEAKPANE_RUNTIME_API__;', + ); +} + +async function importModule(source: string) { + const runtimeGlobal = globalThis as typeof globalThis & RuntimeGlobal; + runtimeGlobal.__AHOLO_RENDER_RUNTIME_API__ = RendererApi; + runtimeGlobal.__AHOLO_TWEAKPANE_RUNTIME_API__ = TweakpaneApi; + + const url = URL.createObjectURL( + new Blob([source], { + type: 'text/javascript', + }), + ); + + try { + return await import(/* @vite-ignore */ url); + } finally { + URL.revokeObjectURL(url); + } +} + +function formatDiagnostic(ts: TypeScriptModule, diagnostic: Diagnostic) { + return ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); +} + +class RenderSessionRenderer implements RuntimeRenderer { + readonly #viewer: Viewer; + readonly #scene: Scene3D; + readonly #camera: Camera3D; + readonly #control: CameraControl; + readonly #onStats: ((stats: RenderStats) => void) | undefined; + readonly #frameCallbacks: FrameCallback[] = []; + readonly #beginFrame: (() => void) | undefined; + readonly #endFrame: (() => void) | undefined; + #rafRequestId: number | undefined; + #resizeTimer: number | undefined; + #lastFrameTime = 0; + #renderRequested = true; + #disposed = false; + #stats: RenderStats = { + drawCalls: 0, + objects: 0, + }; + + constructor( + surface: HTMLElement, + onStats?: (stats: RenderStats) => void, + beginFrame?: () => void, + endFrame?: () => void, + options: RenderSessionRendererOptions = {}, + ) { + this.#onStats = onStats; + this.#beginFrame = beginFrame; + this.#endFrame = endFrame; + + removeEngineCanvases(surface); + this.#viewer = RendererApi.createViewer(`aholo-render-${++renderSessionId}`, surface, { + antialiasing: options.antialiasing ?? false, + }); + RendererApi.setViewerConfig(this.#viewer, { + pixelRatio: (options.pixelRatio ?? 1) / window.devicePixelRatio, + pipeline: { + Background: { + background: { + active: RendererApi.BackgroundMode.BasicBackground, + basic: { + color: new RendererApi.Color(0, 0, 0), + }, + }, + ground: { + enabled: false, + }, + }, + TAA: { + enabled: false, + }, + }, + }); + this.#viewer.requestRenderHandler = this.requestRender; + this.#scene = new RendererApi.Scene3D(); + this.#camera = new RendererApi.PerspectiveCamera(60, 1, 0.1, 2000); + this.#viewer.setScene(this.#scene); + this.#viewer.setCamera(this.#camera); + this.#control = new CameraControl(this.#camera, surface, { + enabled: false, + }); + + this.#resizeTimer = window.setTimeout(() => { + this.resize(); + }, 0); + } + + get control() { + return this.#control; + } + + get viewer() { + return this.#viewer; + } + + get scene() { + return this.#scene; + } + + get stats() { + return this.#stats; + } + + frame(callback: FrameCallback): void { + this.#frameCallbacks.push(callback); + this.requestRender(); + } + + render(): void { + if (this.#disposed) { + return; + } + + syncCameraAspect(this.#viewer.getCamera(), this.#viewer); + this.#viewer.getScene().notifySceneChange(); + this.requestRender(); + } + + requestRender = () => { + this.#renderRequested = true; + }; + + resize(): void { + if (this.#disposed) { + return; + } + + this.#viewer.resize(); + + syncCameraAspect(this.#viewer.getCamera(), this.#viewer); + this.requestRender(); + } + + start() { + if (this.#disposed || this.#rafRequestId !== undefined) { + return; + } + + this.#viewer.resume(); + this.resize(); + this.#rafRequestId = window.requestAnimationFrame(this.#tick); + } + + pause() { + if (this.#disposed || this.#rafRequestId === undefined) { + return; + } + + window.cancelAnimationFrame(this.#rafRequestId); + this.#rafRequestId = undefined; + this.#lastFrameTime = 0; + // Also stops the engine-internal FPS/tick loop so a hidden surface + // schedules no animation frames at all. + this.#viewer.pause(); + } + + dispose() { + if (this.#disposed) { + return; + } + + this.#disposed = true; + + if (this.#resizeTimer !== undefined) { + window.clearTimeout(this.#resizeTimer); + this.#resizeTimer = undefined; + } + + if (this.#rafRequestId !== undefined) { + window.cancelAnimationFrame(this.#rafRequestId); + this.#rafRequestId = undefined; + } + + this.#viewer.requestRenderHandler = undefined; + this.#control.dispose(); + this.#viewer.destroy(); + } + + #tick = (time: number) => { + if (this.#disposed) { + return; + } + this.#beginFrame?.(); + const delta = this.#lastFrameTime > 0 ? Math.min((time - this.#lastFrameTime) / 1000, 0.1) : 0; + this.#lastFrameTime = time; + + let shouldRender = this.#renderRequested; + for (const callback of this.#frameCallbacks) { + shouldRender = callback({ time, delta }) || shouldRender; + } + if (shouldRender) { + this.#renderRequested = false; + this.#viewer.render(); + this.#updateStats(); + } + this.#endFrame?.(); + this.#rafRequestId = window.requestAnimationFrame(this.#tick); + }; + + #updateStats() { + const renderStats = this.#viewer.getRenderStatistics(); + this.#stats = { + drawCalls: Number(renderStats.calls ?? 0), + objects: countSceneObjects(this.#viewer.getScene()), + }; + this.#onStats?.(this.#stats); + } +} + +function prepareRenderSurface(target: HTMLElement, accent: string) { + if (!(target instanceof HTMLCanvasElement)) { + styleRenderSurface(target, accent); + return target; + } + + let surface = renderSurfaces.get(target); + + if (!surface) { + surface = document.createElement('div'); + renderSurfaces.set(target, surface); + target.before(surface); + } else if (!surface.isConnected) { + target.before(surface); + } + + for (const className of target.classList) { + surface.classList.add(className); + } + + target.hidden = true; + target.style.display = 'none'; + target.setAttribute('aria-hidden', 'true'); + + styleRenderSurface(surface, accent, target); + return surface; +} + +function styleRenderSurface(surface: HTMLElement, accent: string, source?: HTMLElement) { + surface.classList.add('renderer-runtime-surface'); + surface.dataset.rendererRuntimeSurface = 'true'; + surface.style.setProperty('--runtime-accent', accent); + surface.style.display = 'block'; + surface.style.width = '100%'; + surface.style.height = '100%'; + surface.style.minHeight = '0'; + surface.style.overflow = 'hidden'; + + if (!source) { + surface.style.position ||= 'relative'; + return; + } + + const sourceStyle = window.getComputedStyle(source); + + if (sourceStyle.position === 'absolute' || sourceStyle.position === 'fixed') { + surface.style.position = sourceStyle.position; + surface.style.inset = '0'; + surface.style.zIndex = sourceStyle.zIndex; + } else { + surface.style.position = 'relative'; + } +} + +function removeEngineCanvases(surface: HTMLElement) { + for (const child of Array.from(surface.children)) { + if (child instanceof HTMLCanvasElement && child.dataset.engine) { + child.remove(); + } + } +} diff --git a/website/src/client/rendering.ts b/website/src/client/rendering.ts new file mode 100644 index 0000000..17f8426 --- /dev/null +++ b/website/src/client/rendering.ts @@ -0,0 +1,77 @@ +import type { Camera3D, Viewer } from '@manycore/aholo-viewer'; + +export function syncCameraAspect(camera: Camera3D, viewer: Viewer) { + if (!('aspect' in camera)) { + return; + } + + const { width, height } = viewer.getSize(); + const aspect = width > 0 && height > 0 ? width / height : 1; + const perspectiveCamera = camera as Camera3D & { + aspect: number; + updateProjectionMatrix?: () => void; + }; + + if (Number.isFinite(aspect) && Math.abs(perspectiveCamera.aspect - aspect) > 0.001) { + perspectiveCamera.aspect = aspect; + perspectiveCamera.updateProjectionMatrix?.(); + } +} + +export function countSceneObjects(scene: unknown) { + let count = 0; + + function visit(node: unknown) { + if (!node || typeof node !== 'object') { + return; + } + + if (node !== scene && isRenderableSceneObject(node)) { + count += 1; + } + + if ('children' in node && Array.isArray(node.children)) { + for (const child of node.children) { + visit(child); + } + } + } + + visit(scene); + return count; +} + +export async function abortable(promise: Promise, signal: AbortSignal, abortMessage: string): Promise { + throwIfAborted(signal, abortMessage); + + return new Promise((resolve, reject) => { + const abort = () => { + reject(createAbortError(abortMessage)); + }; + + signal.addEventListener('abort', abort, { once: true }); + promise.then(resolve, reject).finally(() => { + signal.removeEventListener('abort', abort); + }); + }); +} + +export function throwIfAborted(signal: AbortSignal, abortMessage: string) { + if (signal.aborted) { + throw createAbortError(abortMessage); + } +} + +export function createAbortError(abortMessage: string) { + return new DOMException(abortMessage, 'AbortError'); +} + +function isRenderableSceneObject(node: object) { + return ( + ('isMesh' in node && node.isMesh === true) || + ('isSplat' in node && node.isSplat === true) || + ('isSprite' in node && node.isSprite === true) || + ('isPoints' in node && node.isPoints === true) || + ('isLine' in node && node.isLine === true) + ); +} diff --git a/website/src/client/split-pane.ts b/website/src/client/split-pane.ts new file mode 100644 index 0000000..b2d8caf --- /dev/null +++ b/website/src/client/split-pane.ts @@ -0,0 +1,241 @@ +interface SplitPaneController { + dispose(): void; + getCollapsed(): boolean; + getSize(): number; + setCollapsed(collapsed: boolean, options?: SplitPaneUpdateOptions): void; + setSize(value: number, options?: SplitPaneUpdateOptions): void; +} + +interface SplitPaneUpdateOptions { + persist?: boolean; +} + +interface SplitPaneOptions { + container: HTMLElement; + splitter: HTMLElement; + collapsedDatasetKey: string; + cssProperty: string; + defaultValue: number; + valueUnit: 'px' | '%'; + keyboardStep: number; + clampValue(value: number, rect: DOMRect): number; + shouldCollapse(value: number, rect: DOMRect): boolean; + expandKey?: 'ArrowLeft' | 'ArrowRight'; + getAriaValue?: (value: number, rect: DOMRect) => number; + getInitialCollapsed?: () => boolean; + isDisabled?: () => boolean; + onChange?: () => void; + onCollapsedChange?: (collapsed: boolean, options: Required) => void; + onValueChange?: (value: number, options: Required) => void; + pointerToValue?: (event: PointerEvent, rect: DOMRect) => number; + toggleWhenDisabled?: boolean; +} + +const DEFAULT_UPDATE_OPTIONS: Required = { + persist: true, +}; + +export function mountSplitPane(options: SplitPaneOptions): SplitPaneController { + const { + container, + splitter, + collapsedDatasetKey, + cssProperty, + defaultValue, + valueUnit, + keyboardStep, + clampValue, + shouldCollapse, + expandKey = 'ArrowRight', + getAriaValue = value => value, + getInitialCollapsed = () => false, + isDisabled = () => false, + onChange, + onCollapsedChange, + onValueChange, + pointerToValue = (event, rect) => event.clientX - rect.left, + toggleWhenDisabled = false, + } = options; + const collapseKey = expandKey === 'ArrowRight' ? 'ArrowLeft' : 'ArrowRight'; + const resizeLabel = splitter.dataset.resizeLabel ?? splitter.getAttribute('aria-label') ?? 'Resize panes'; + const expandLabel = splitter.dataset.expandLabel ?? 'Expand pane'; + + let activePointerId: number | undefined; + let didDrag = false; + let pointerStartX = 0; + let collapsed = getInitialCollapsed(); + let value = defaultValue; + + setSize(value, { persist: false }); + setCollapsed(collapsed, { persist: false }); + + splitter.addEventListener('pointerdown', handlePointerDown); + splitter.addEventListener('pointermove', handlePointerMove); + splitter.addEventListener('pointerup', endPointerResize); + splitter.addEventListener('pointercancel', endPointerResize); + splitter.addEventListener('click', handleClick); + splitter.addEventListener('keydown', handleKeydown); + + return { + dispose() { + splitter.removeEventListener('pointerdown', handlePointerDown); + splitter.removeEventListener('pointermove', handlePointerMove); + splitter.removeEventListener('pointerup', endPointerResize); + splitter.removeEventListener('pointercancel', endPointerResize); + splitter.removeEventListener('click', handleClick); + splitter.removeEventListener('keydown', handleKeydown); + + if (activePointerId !== undefined && splitter.hasPointerCapture(activePointerId)) { + splitter.releasePointerCapture(activePointerId); + } + + activePointerId = undefined; + delete container.dataset.resizing; + }, + getCollapsed() { + return collapsed; + }, + getSize() { + return value; + }, + setCollapsed, + setSize, + }; + + function handlePointerDown(event: PointerEvent) { + if (isDisabled()) { + return; + } + + activePointerId = event.pointerId; + pointerStartX = event.clientX; + didDrag = false; + splitter.setPointerCapture(event.pointerId); + container.dataset.resizing = 'true'; + updateFromPointer(event); + } + + function handlePointerMove(event: PointerEvent) { + if (activePointerId !== event.pointerId) { + return; + } + + didDrag = didDrag || Math.abs(event.clientX - pointerStartX) > 4; + updateFromPointer(event); + } + + function endPointerResize(event: PointerEvent) { + if (activePointerId !== event.pointerId) { + return; + } + + if (splitter.hasPointerCapture(event.pointerId)) { + splitter.releasePointerCapture(event.pointerId); + } + + activePointerId = undefined; + delete container.dataset.resizing; + } + + function handleClick() { + if (didDrag) { + didDrag = false; + return; + } + + if (isDisabled()) { + if (toggleWhenDisabled) { + setCollapsed(!collapsed); + } + return; + } + + if (collapsed) { + setCollapsed(false); + } + } + + function handleKeydown(event: KeyboardEvent) { + if (isDisabled() || (event.key !== collapseKey && event.key !== expandKey)) { + return; + } + + event.preventDefault(); + + if (collapsed) { + if (event.key === expandKey) { + setCollapsed(false); + } + return; + } + + const direction = event.key === collapseKey ? -1 : 1; + const nextValue = value + direction * keyboardStep; + const rect = container.getBoundingClientRect(); + + if (event.key === collapseKey && shouldCollapse(nextValue, rect)) { + setCollapsed(true); + return; + } + + setSize(nextValue); + } + + function updateFromPointer(event: PointerEvent) { + const rect = container.getBoundingClientRect(); + const nextValue = pointerToValue(event, rect); + + if (shouldCollapse(nextValue, rect)) { + setCollapsed(true); + return; + } + + setSize(nextValue); + } + + function setCollapsed(nextCollapsed: boolean, updateOptions: SplitPaneUpdateOptions = {}) { + const resolvedOptions = { + ...DEFAULT_UPDATE_OPTIONS, + ...updateOptions, + }; + + collapsed = nextCollapsed; + + if (!nextCollapsed) { + applyValue(value); + } + + syncSplitter(); + onCollapsedChange?.(nextCollapsed, resolvedOptions); + onChange?.(); + } + + function setSize(nextValue: number, updateOptions: SplitPaneUpdateOptions = {}) { + const resolvedOptions = { + ...DEFAULT_UPDATE_OPTIONS, + ...updateOptions, + }; + const rect = container.getBoundingClientRect(); + + value = clampValue(nextValue, rect); + collapsed = false; + applyValue(value); + syncSplitter(); + onValueChange?.(value, resolvedOptions); + onCollapsedChange?.(false, resolvedOptions); + onChange?.(); + } + + function applyValue(nextValue: number) { + container.style.setProperty(cssProperty, `${nextValue}${valueUnit}`); + } + + function syncSplitter() { + container.dataset[collapsedDatasetKey] = String(collapsed); + splitter.setAttribute('aria-expanded', String(!collapsed)); + splitter.setAttribute('aria-label', collapsed ? expandLabel : resizeLabel); + + const ariaValue = collapsed ? 0 : Math.round(getAriaValue(value, container.getBoundingClientRect())); + splitter.setAttribute('aria-valuenow', String(ariaValue)); + } +} diff --git a/website/src/client/viewer.ts b/website/src/client/viewer.ts new file mode 100644 index 0000000..bd3224e --- /dev/null +++ b/website/src/client/viewer.ts @@ -0,0 +1,1596 @@ +import { + BackgroundMode, + Color, + PerspectiveCamera, + Scene3D, + SplatLoader, + SplatUtils, + ToneMapping, + Vector3, + createViewer, + createViewerContext, + setViewerConfig, + type Object3D, + type Viewer, +} from '@manycore/aholo-viewer'; +import { Pane } from 'tweakpane'; +import { CameraControl } from './camera-control.js'; +import { + abortable as abortableWithMessage, + syncCameraAspect, + throwIfAborted as throwIfAbortedWithMessage, +} from './rendering.js'; +import { mountWorkspaceFullscreenMode } from './workspace-fullscreen.js'; + +const { SplatFileType, SplatPackType, detectSplatFileType, parseSplatData } = SplatLoader; +const { LodSplat, createSplat } = SplatUtils; +const LOD_MAGIC_CODE = 0x262834; +const VIEWER_ABORT_MESSAGE = 'Viewer loading was aborted.'; +const LEFT_RAIL_COLLAPSED_STORAGE_KEY = 'aholo:viewer:left-collapsed'; +const RIGHT_RAIL_COLLAPSED_STORAGE_KEY = 'aholo:viewer:right-collapsed'; +const FPS_DISPLAY_INTERVAL_MS = 250; +const FPS_SMOOTHING_FACTOR = 0.08; +const SUPPORTED_FILE_EXTENSIONS = ['.ply', '.spz', '.splat', '.ksplat', '.lcc', '.sog', '.esz', '.json'] as const; + +type SplatFileTypeValue = (typeof SplatFileType)[keyof typeof SplatFileType]; +type SplatPackTypeValue = (typeof SplatPackType)[keyof typeof SplatPackType]; +type ToneMappingValue = (typeof ToneMapping)[keyof typeof ToneMapping]; +type SplattingPresetId = + | 'custom' + | 'maxQuality' + | 'qualityFirst' + | 'balanced' + | 'performanceFirst' + | 'extremePerformance0' + | 'extremePerformance1'; +type SplatObject = Awaited>; +type LodSplatInstance = InstanceType; +type LodMeta = ConstructorParameters[0] & { + counts?: number; + files?: string[]; + levels?: number; + magicCode?: number; + type?: string; +}; + +interface ViewerPageConfig { + labels: ViewerLabels; +} + +interface ViewerLabels { + settings: string; + preset: string; + presetCustom: string; + presetMaxQuality: string; + presetQualityFirst: string; + presetBalanced: string; + presetPerformanceFirst: string; + presetExtremePerformance0: string; + presetExtremePerformance1: string; + presetPackTypeTip: string; + packTypeTip: string; + statusReady: string; + statusLoading: string; + statusError: string; + unsupportedFileType: string; + emptyFiles: string; + removeFile: string; +} + +type Source = + | { + kind: 'file'; + file: File; + name: string; + size: number; + } + | { + kind: 'url'; + url: string; + name: string; + size?: number; + }; + +interface FileRecord { + name: string; + format: string; + size: string; + status: 'loading' | 'ready' | 'error'; + error?: string; +} + +interface ViewerParams { + splattingPreset: SplattingPresetId; + pixelRatio: number; + splatPackType: SplatPackTypeValue; + maxSh: number; + maxStdDev: number; + packHighPrecisionEnabled: boolean; + precalculateEnabled: boolean; + cameraRelativeEnabled: boolean; + sortedLayoutEnabled: boolean; + autoFreeResourceOnGpuPacked: boolean; + renderAttachHighPrecisionEnabled: boolean; + normalizedFalloff: boolean; + preBlurAmount: number; + blurAmount: number; + focalAdjustment: number; + detailCullingThreshold: number; + maxPixelRadius: number; + minIntervalMs: number; + sortSplatDistance: number; + sortSplatCoorient: number; + sortCameraDistance: number; + sortCameraCoorient: number; + sortHighPrecisionEnabled: boolean; + toneMappingEnabled: boolean; + toneMapping: ToneMappingValue; + exposure: number; + highlightEnabled: boolean; + highlightSize: number; + highlightColor: string; + lodMinLevel: number; + lodMaxBudget: number; + lodBackgroundPenalty: number; + lodOutsidePenalty: number; + lodBehindPenalty: number; + lodBehindTolerance: number; + lodBehindDistanceTolerance: number; + lodHysteresisTicks: number; + lodSchedulerParallelCounts: number; + lodSchedulerExistingTaskLimit: number; + lodSchedulerMinDuration: number; +} + +const DEFAULT_SORT_PARAMS: Pick< + ViewerParams, + 'minIntervalMs' | 'sortSplatDistance' | 'sortSplatCoorient' | 'sortCameraDistance' | 'sortCameraCoorient' +> = { + minIntervalMs: 0, + sortSplatDistance: 0.1, + sortSplatCoorient: 0.99999, + sortCameraDistance: 1, + sortCameraCoorient: 0.99, +}; + +const EXTREME_PERFORMANCE_SORT_PARAMS: typeof DEFAULT_SORT_PARAMS = { + ...DEFAULT_SORT_PARAMS, + minIntervalMs: 160, + sortSplatCoorient: 0.999999, +}; + +const VIEWER_SPLATTING_PRESETS: Record }> = { + custom: { + params: {}, + }, + maxQuality: { + params: { + ...DEFAULT_SORT_PARAMS, + splatPackType: SplatPackType.Compressed, + maxSh: 3, + maxStdDev: 8, + packHighPrecisionEnabled: true, + precalculateEnabled: true, + cameraRelativeEnabled: false, + sortedLayoutEnabled: false, + autoFreeResourceOnGpuPacked: true, + renderAttachHighPrecisionEnabled: true, + normalizedFalloff: true, + preBlurAmount: 0.3, + blurAmount: 0, + focalAdjustment: 2, + detailCullingThreshold: 0, + maxPixelRadius: 1024, + sortHighPrecisionEnabled: true, + }, + }, + qualityFirst: { + params: { + ...DEFAULT_SORT_PARAMS, + splatPackType: SplatPackType.Compressed, + maxSh: 3, + maxStdDev: 8, + packHighPrecisionEnabled: true, + precalculateEnabled: true, + cameraRelativeEnabled: false, + sortedLayoutEnabled: false, + autoFreeResourceOnGpuPacked: true, + renderAttachHighPrecisionEnabled: false, + normalizedFalloff: false, + preBlurAmount: 0.3, + blurAmount: 0, + focalAdjustment: 2, + detailCullingThreshold: 1, + maxPixelRadius: 1024, + sortHighPrecisionEnabled: false, + }, + }, + balanced: { + params: { + ...DEFAULT_SORT_PARAMS, + splatPackType: SplatPackType.SuperCompressed, + maxSh: 3, + maxStdDev: 8, + packHighPrecisionEnabled: false, + precalculateEnabled: true, + cameraRelativeEnabled: true, + sortedLayoutEnabled: false, + autoFreeResourceOnGpuPacked: false, + renderAttachHighPrecisionEnabled: false, + normalizedFalloff: false, + preBlurAmount: 0.3, + blurAmount: 0, + focalAdjustment: 2, + detailCullingThreshold: 1, + maxPixelRadius: 1024, + sortHighPrecisionEnabled: false, + }, + }, + performanceFirst: { + params: { + ...DEFAULT_SORT_PARAMS, + splatPackType: SplatPackType.SuperCompressed, + maxSh: 3, + maxStdDev: 5, + packHighPrecisionEnabled: false, + precalculateEnabled: true, + cameraRelativeEnabled: false, + sortedLayoutEnabled: false, + autoFreeResourceOnGpuPacked: true, + renderAttachHighPrecisionEnabled: false, + normalizedFalloff: false, + preBlurAmount: 0.3, + blurAmount: 0, + focalAdjustment: 2, + detailCullingThreshold: 1, + maxPixelRadius: 1024, + sortHighPrecisionEnabled: false, + }, + }, + extremePerformance0: { + params: { + ...EXTREME_PERFORMANCE_SORT_PARAMS, + splatPackType: SplatPackType.SuperCompressed, + maxSh: 0, + maxStdDev: 5, + packHighPrecisionEnabled: false, + precalculateEnabled: false, + cameraRelativeEnabled: false, + sortedLayoutEnabled: true, + autoFreeResourceOnGpuPacked: true, + renderAttachHighPrecisionEnabled: false, + normalizedFalloff: false, + preBlurAmount: 0.3, + blurAmount: 0, + focalAdjustment: 2, + detailCullingThreshold: 4, + maxPixelRadius: 1024, + sortHighPrecisionEnabled: false, + }, + }, + extremePerformance1: { + params: { + ...EXTREME_PERFORMANCE_SORT_PARAMS, + splatPackType: SplatPackType.Sog, + maxSh: 0, + maxStdDev: 5, + packHighPrecisionEnabled: false, + precalculateEnabled: false, + cameraRelativeEnabled: false, + sortedLayoutEnabled: true, + autoFreeResourceOnGpuPacked: true, + renderAttachHighPrecisionEnabled: false, + normalizedFalloff: false, + preBlurAmount: 0.3, + blurAmount: 0, + focalAdjustment: 2, + detailCullingThreshold: 4, + maxPixelRadius: 1024, + sortHighPrecisionEnabled: false, + }, + }, +}; + +export async function mountViewerPage(root: HTMLElement, config: ViewerPageConfig) { + const surface = query(root, '[data-render-surface]'); + const stage = query(root, '[data-viewer-stage]'); + const status = query(root, '[data-status]'); + const leftRail = query(root, '[data-left-rail]'); + const leftToggle = query(root, '[data-left-toggle]'); + const leftClose = query(root, '[data-left-close]'); + const rightRail = query(root, '[data-right-rail]'); + const rightToggle = query(root, '[data-right-toggle]'); + const rightClose = query(root, '[data-right-close]'); + const fileInput = query(root, '[data-file-input]'); + const urlForm = query(root, '[data-url-form]'); + const urlInput = query(root, '[data-url-input]'); + const clipboardLoad = query(root, '[data-clipboard-load]'); + const fileList = query(root, '[data-file-list]'); + const coordSelect = query(root, '[data-camera-coord]'); + const farInput = query(root, '[data-camera-far]'); + const useOrbitInput = query(root, '[data-camera-use-orbit]'); + const copyCameraButton = query(root, '[data-camera-copy]'); + const pasteCameraButton = query(root, '[data-camera-paste]'); + const resetCameraButton = query(root, '[data-camera-reset]'); + const configPanel = query(root, '[data-config-panel]'); + const fpsStat = query(root, '[data-stat-fps]'); + const supportedFileExtensions = getSupportedFileExtensions(fileInput); + + const params = createDefaultParams(); + const viewer = createViewer(`aholo-viewer-page-${Date.now()}`, surface, { antialiasing: false }); + const scene = new Scene3D(); + const camera = new PerspectiveCamera(60, 1, 0.1, Number(farInput.value) || 2000); + const control = new CameraControl(camera, surface, { enabled: true }); + + let disposed = false; + let rafId: number | undefined; + let lastFrameTime = 0; + let renderRequested = true; + let loadAbortController: AbortController | undefined; + let leftRailCollapsed = readLeftRailCollapsed(); + let rightRailCollapsed = readRightRailCollapsed(); + let fileDragDepth = 0; + let keepPrecalculateDisabled = false; + const lodSplats: LodSplatInstance[] = []; + const records: FileRecord[] = []; + const estimateFps = createFpsEstimator(); + + configureFileInputForPlatform(fileInput); + + const pane = setupConfigPanel( + configPanel, + config.labels, + params, + () => keepPrecalculateDisabled, + () => { + applyViewerConfig(viewer, params); + syncLodConfig(); + requestRender(); + }, + ); + const fullscreenMode = mountWorkspaceFullscreenMode({ + onChange() { + resize(); + }, + }); + const resizeObserver = new ResizeObserver(() => resize()); + + syncLeftRail(); + syncRightRail(); + viewer.setScene(scene); + viewer.setCamera(camera); + applyViewerConfig(viewer, params); + applyCoordinateSystem(coordSelect.value); + resizeObserver.observe(stage); + resize(); + tick(); + setStatus('ready', config.labels.statusReady); + + fileInput.addEventListener('change', () => { + const files = Array.from(fileInput.files ?? []); + if (files.length > 0) { + void loadSources(files.map(fileToSource)); + } + fileInput.value = ''; + }); + root.addEventListener('dragenter', handleDragEnter); + root.addEventListener('dragover', handleDragOver); + root.addEventListener('dragleave', handleDragLeave); + root.addEventListener('drop', handleDrop); + urlForm.addEventListener('submit', event => { + event.preventDefault(); + const sources = parseUrls(urlInput.value).map(urlToSource); + if (sources.length > 0) { + void loadSources(sources); + } + }); + clipboardLoad.addEventListener('click', async () => { + try { + const text = await navigator.clipboard.readText(); + urlInput.value = text; + const sources = parseUrls(text).map(urlToSource); + if (sources.length > 0) { + void loadSources(sources); + } + } catch (error) { + setStatus('error', getErrorMessage(error)); + } + }); + fileList.addEventListener('click', event => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const deleteButton = target.closest('[data-file-delete]'); + if (!deleteButton || !fileList.contains(deleteButton)) { + return; + } + + clearLoadedScene(); + }); + coordSelect.addEventListener('change', () => { + applyCoordinateSystem(coordSelect.value); + requestRender(); + }); + farInput.addEventListener('change', () => { + const far = Number(farInput.value); + if (Number.isFinite(far) && far > camera.near) { + camera.far = far; + camera.updateProjectionMatrix(); + requestRender(); + } + }); + useOrbitInput.addEventListener('change', () => { + control.setOptions({ useOrbit: useOrbitInput.checked }); + requestRender(); + }); + copyCameraButton.addEventListener('click', () => { + void navigator.clipboard.writeText(JSON.stringify(serializeCamera(camera))); + }); + pasteCameraButton.addEventListener('click', async () => { + try { + applyCameraState(camera, JSON.parse(await navigator.clipboard.readText())); + farInput.value = String(camera.far); + requestRender(); + } catch (error) { + setStatus('error', getErrorMessage(error)); + } + }); + resetCameraButton.addEventListener('click', () => { + applyCoordinateSystem(coordSelect.value); + requestRender(); + }); + window.addEventListener('resize', resize); + window.addEventListener('beforeunload', dispose, { once: true }); + document.addEventListener('astro:before-swap', dispose, { once: true }); + leftToggle.addEventListener('click', handleLeftExpandClick); + leftClose.addEventListener('click', handleLeftCloseClick); + rightToggle.addEventListener('click', handleRightExpandClick); + rightClose.addEventListener('click', handleRightCloseClick); + + function handleLeftExpandClick() { + setLeftRailCollapsed(false); + } + + function handleLeftCloseClick() { + setLeftRailCollapsed(true); + } + + function handleRightExpandClick() { + setRightRailCollapsed(false); + } + + function handleRightCloseClick() { + setRightRailCollapsed(true); + } + + function setLeftRailCollapsed(nextCollapsed: boolean) { + leftRailCollapsed = nextCollapsed; + writeLeftRailCollapsed(nextCollapsed); + syncLeftRail(); + resize(); + } + + function setRightRailCollapsed(nextCollapsed: boolean) { + rightRailCollapsed = nextCollapsed; + writeRightRailCollapsed(nextCollapsed); + syncRightRail(); + resize(); + } + + function syncLeftRail() { + root.dataset.leftCollapsed = String(leftRailCollapsed); + document.documentElement.dataset.viewerLeftCollapsed = String(leftRailCollapsed); + leftRail.setAttribute('aria-hidden', String(leftRailCollapsed)); + leftRail.toggleAttribute('inert', leftRailCollapsed); + leftToggle.setAttribute('aria-expanded', String(!leftRailCollapsed)); + const label = leftToggle.dataset.expandLabel; + if (label) { + leftToggle.setAttribute('aria-label', label); + leftToggle.title = label; + } + } + + function syncRightRail() { + root.dataset.rightCollapsed = String(rightRailCollapsed); + document.documentElement.dataset.viewerRightCollapsed = String(rightRailCollapsed); + rightRail.setAttribute('aria-hidden', String(rightRailCollapsed)); + rightRail.toggleAttribute('inert', rightRailCollapsed); + rightToggle.setAttribute('aria-expanded', String(!rightRailCollapsed)); + const label = rightToggle.dataset.expandLabel; + if (label) { + rightToggle.setAttribute('aria-label', label); + rightToggle.title = label; + } + } + + async function loadSources(sources: Source[]) { + const abortController = new AbortController(); + loadAbortController?.abort(); + loadAbortController = abortController; + clearScene(); + keepPrecalculateDisabled = false; + records.length = 0; + records.push(...sources.map(createFileRecord)); + renderRecords(); + setStatus('loading', config.labels.statusLoading); + + let loaded = 0; + for (let index = 0; index < sources.length; index++) { + const source = sources[index]; + const record = records[index]; + if (!record) { + continue; + } + + try { + validateImportSource(source, supportedFileExtensions, config.labels.unsupportedFileType); + await loadSource(source, record, abortController.signal); + if (abortController.signal.aborted) { + return; + } + record.status = 'ready'; + loaded++; + } catch (error) { + if (abortController.signal.aborted) { + return; + } + record.status = 'error'; + record.error = getErrorMessage(error); + } + renderRecords(); + requestRender(); + } + + if (loadAbortController === abortController) { + loadAbortController = undefined; + } + + setStatus(loaded > 0 ? 'ready' : 'error', loaded > 0 ? config.labels.statusReady : config.labels.statusError); + requestRender(); + } + + function clearLoadedScene() { + loadAbortController?.abort(); + clearScene(); + keepPrecalculateDisabled = false; + records.length = 0; + renderRecords(); + setStatus('ready', config.labels.statusReady); + requestRender(); + } + + async function loadSource(source: Source, record: FileRecord, signal: AbortSignal) { + throwIfAborted(signal); + const json = await readJsonIfNeeded(source, signal); + + if (json && isLodMeta(json)) { + record.format = 'LOD JSON'; + await loadLodMeta(json, source.kind === 'url' ? source.url : undefined, signal); + return; + } + + const probe = await getDetectionProbe(source, json); + const type = detectSplatFileType(source.name, probe); + + if (type === undefined) { + throw new Error(`Unsupported file type: ${source.name}`); + } + + record.format = getFileTypeLabel(type); + const data = await parseSourceData(source, type, signal); + applyImportedSplatDefaults(data); + const splat = await abortable(createSplat(data), signal); + splat.autoFreeResourceOnGpuPacked = params.autoFreeResourceOnGpuPacked; + scene.add(splat as Object3D); + } + + async function parseSourceData(source: Source, type: SplatFileTypeValue, signal: AbortSignal) { + const input = source.kind === 'url' ? source.url : source.file; + return abortable( + parseSplatData(type, input, params.splatPackType, { + maxShDegree: params.maxSh, + maxTextureSize: 8192, + }), + signal, + ); + } + + function applyImportedSplatDefaults(data: { readonly shDegree: number }) { + if (data.shDegree !== 0) { + return; + } + + keepPrecalculateDisabled = true; + params.precalculateEnabled = false; + pane.refreshProgrammatic(); + applyViewerConfig(viewer, params); + requestRender(); + } + + async function loadLodMeta(meta: LodMeta, baseUrl: string | undefined, signal: AbortSignal) { + let rejectResourceError: (error: unknown) => void = () => {}; + const resourceError = new Promise((_resolve, reject) => { + rejectResourceError = reject; + }); + resourceError.catch(() => {}); + + const lodSplat = new LodSplat( + meta, + { + ...getLodConfig(params), + minLevel: getInitialLodLevel(meta, params), + schedulerParallelCounts: 99999, + schedulerExistingTaskLimit: 99999, + schedulerMinDuration: 0, + }, + createViewerContext(viewer), + async (url: string) => { + try { + return await loadLodResource(url, baseUrl, signal); + } catch (error) { + rejectResourceError(error); + throw error; + } + }, + ); + + lodSplats.push(lodSplat); + scene.add(lodSplat.container); + lodSplat.tick(camera); + lodSplat.start(); + requestRender(); + + await Promise.race([abortable(lodSplat.onFinishSchedule(), signal), resourceError]); + throwIfAborted(signal); + lodSplat.setConfig(getLodConfig(params)); + lodSplat.tick(camera); + } + + async function loadLodResource(url: string, baseUrl: string | undefined, signal: AbortSignal) { + throwIfAborted(signal); + const resourceUrl = resolveResourceUrl(url, baseUrl); + const type = detectSplatFileType(resourceUrl, new Uint8Array()); + + if (type === undefined) { + throw new Error(`Unsupported LOD resource: ${resourceUrl}`); + } + + const data = await abortable(parseSplatData(type, resourceUrl, SplatPackType.Compressed), signal); + applyImportedSplatDefaults(data); + return data; + } + + function clearScene() { + for (const lodSplat of lodSplats.splice(0)) { + lodSplat.destroy(); + } + + for (const child of scene.removeAllChildren()) { + disposeSceneObject(child); + } + scene.notifySceneChange(); + } + + function syncLodConfig() { + const lodConfig = getLodConfig(params); + for (const lodSplat of lodSplats) { + lodSplat.setConfig(lodConfig); + lodSplat.tick(camera); + } + } + + function resize() { + viewer.resize(); + syncCameraAspect(camera, viewer); + requestRender(); + } + + function requestRender() { + renderRequested = true; + } + + function tick(time = performance.now()) { + if (disposed) { + return; + } + + const delta = lastFrameTime > 0 ? Math.min((time - lastFrameTime) / 1000, 0.1) : 0; + lastFrameTime = time; + const controlUpdated = control.update(delta); + let lodUpdated = false; + + for (const lodSplat of lodSplats) { + lodSplat.tick(camera); + lodUpdated = true; + } + + if (renderRequested || controlUpdated || lodUpdated) { + syncCameraAspect(camera, viewer); + scene.notifySceneChange(); + } + + renderRequested = false; + viewer.render(); + updateStats(); + rafId = window.requestAnimationFrame(tick); + } + + function updateStats() { + fpsStat.textContent = estimateFps().toString(); + } + + function applyCoordinateSystem(value: string) { + switch (value) { + case 'aholo': + camera.up.set(0, 0, 1); + camera.position.set(0, -3, 1.2); + break; + case 'opengl': + camera.up.set(0, 1, 0); + camera.position.set(0, 1.2, 3); + break; + case 'opencv': + default: + camera.up.set(0, -1, 0); + camera.position.set(0, -1.2, 3); + break; + } + + camera.lookAt(new Vector3(0, 0, 0)); + camera.far = Number(farInput.value) || 2000; + camera.updateProjectionMatrix(); + control.stop(); + } + + function setStatus(state: 'loading' | 'ready' | 'error', message: string) { + status.dataset.state = state; + status.textContent = state === 'error' ? message : ''; + } + + function handleDragEnter(event: DragEvent) { + if (!isFileDrag(event)) { + return; + } + + event.preventDefault(); + fileDragDepth++; + setDropActive(true); + } + + function handleDragOver(event: DragEvent) { + if (!isFileDrag(event)) { + return; + } + + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + setDropActive(true); + } + + function handleDragLeave(event: DragEvent) { + if (!isFileDrag(event)) { + return; + } + + event.preventDefault(); + fileDragDepth = Math.max(0, fileDragDepth - 1); + + if (fileDragDepth === 0) { + setDropActive(false); + } + } + + function handleDrop(event: DragEvent) { + const files = Array.from(event.dataTransfer?.files ?? []); + + if (files.length === 0) { + return; + } + + event.preventDefault(); + fileDragDepth = 0; + setDropActive(false); + + if (files.length > 0) { + void loadSources(files.map(fileToSource)); + } + } + + function setDropActive(active: boolean) { + root.dataset.fileDragging = String(active); + } + + function renderRecords() { + renderFileList(fileList, records, config); + } + + function isFileDrag(event: DragEvent) { + return Array.from(event.dataTransfer?.types ?? []).includes('Files'); + } + + function dispose() { + if (disposed) { + return; + } + + disposed = true; + loadAbortController?.abort(); + resizeObserver.disconnect(); + root.removeEventListener('dragenter', handleDragEnter); + root.removeEventListener('dragover', handleDragOver); + root.removeEventListener('dragleave', handleDragLeave); + root.removeEventListener('drop', handleDrop); + window.removeEventListener('resize', resize); + leftToggle.removeEventListener('click', handleLeftExpandClick); + leftClose.removeEventListener('click', handleLeftCloseClick); + rightToggle.removeEventListener('click', handleRightExpandClick); + rightClose.removeEventListener('click', handleRightCloseClick); + if (rafId !== undefined) { + window.cancelAnimationFrame(rafId); + } + control.dispose(); + clearScene(); + fullscreenMode.dispose(); + pane.dispose(); + viewer.destroy(); + } + + renderRecords(); +} + +function createDefaultParams(): ViewerParams { + return { + splattingPreset: 'balanced', + pixelRatio: 1, + splatPackType: SplatPackType.SuperCompressed, + maxSh: 3, + maxStdDev: 8, + packHighPrecisionEnabled: false, + precalculateEnabled: true, + cameraRelativeEnabled: true, + sortedLayoutEnabled: false, + autoFreeResourceOnGpuPacked: false, + renderAttachHighPrecisionEnabled: false, + normalizedFalloff: false, + preBlurAmount: 0.3, + blurAmount: 0, + focalAdjustment: 2, + detailCullingThreshold: 1, + maxPixelRadius: 1024, + minIntervalMs: 0, + sortSplatDistance: 0.1, + sortSplatCoorient: 0.99999, + sortCameraDistance: 1, + sortCameraCoorient: 0.99, + sortHighPrecisionEnabled: false, + toneMappingEnabled: false, + toneMapping: ToneMapping.Linear, + exposure: 1, + highlightEnabled: false, + highlightSize: 2, + highlightColor: '#0000ff', + lodMinLevel: 0, + lodMaxBudget: 3_000_000, + lodBackgroundPenalty: 0.5, + lodOutsidePenalty: 0.4, + lodBehindPenalty: 0.1, + lodBehindTolerance: -0.2, + lodBehindDistanceTolerance: 2, + lodHysteresisTicks: 4, + lodSchedulerParallelCounts: 4, + lodSchedulerExistingTaskLimit: 64, + lodSchedulerMinDuration: 160, + }; +} + +function applySplattingPreset( + params: ViewerParams, + preset: SplattingPresetId, + options: { keepPrecalculateDisabled: boolean }, +) { + Object.assign(params, VIEWER_SPLATTING_PRESETS[preset].params, { + splattingPreset: preset, + }); + + if (options.keepPrecalculateDisabled) { + params.precalculateEnabled = false; + } +} + +function createSplattingPresetOptions(labels: ViewerLabels) { + return { + [labels.presetCustom]: 'custom', + [labels.presetMaxQuality]: 'maxQuality', + [labels.presetQualityFirst]: 'qualityFirst', + [labels.presetBalanced]: 'balanced', + [labels.presetPerformanceFirst]: 'performanceFirst', + [labels.presetExtremePerformance0]: 'extremePerformance0', + [labels.presetExtremePerformance1]: 'extremePerformance1', + } satisfies Record; +} + +function setupConfigPanel( + container: HTMLElement, + labels: ViewerLabels, + params: ViewerParams, + shouldKeepPrecalculateDisabled: () => boolean, + onChange: () => void, +) { + const pane = new Pane({ + container, + expanded: true, + title: labels.settings, + }); + const presetBinding = pane.addBinding(params, 'splattingPreset', { + label: labels.preset, + options: createSplattingPresetOptions(labels), + }); + addViewerConfigTip(presetBinding, labels.presetPackTypeTip); + let isApplyingSplattingPreset = false; + const refreshProgrammatic = () => { + isApplyingSplattingPreset = true; + pane.refresh(); + queueMicrotask(() => { + isApplyingSplattingPreset = false; + }); + }; + presetBinding.on('change', event => { + isApplyingSplattingPreset = true; + applySplattingPreset(params, event.value, { keepPrecalculateDisabled: shouldKeepPrecalculateDisabled() }); + pane.refresh(); + onChange(); + queueMicrotask(() => { + isApplyingSplattingPreset = false; + }); + }); + const pixelRatioBinding = pane.addBinding(params, 'pixelRatio', { + label: 'Pixel Ratio', + max: Math.max(2, window.devicePixelRatio), + min: 0.5, + step: 0.1, + }); + pixelRatioBinding.on('change', onChange); + const markCustomPreset = () => { + if (isApplyingSplattingPreset || params.splattingPreset === 'custom') { + return; + } + + params.splattingPreset = 'custom'; + presetBinding.refresh(); + }; + const handleSplattingParamChange = () => { + markCustomPreset(); + onChange(); + }; + + const importOptions = pane.addFolder({ title: `Import · ${labels.packTypeTip}`, expanded: false }); + const pack = pane.addFolder({ title: 'Pack', expanded: false }); + const raster = pane.addFolder({ title: 'Raster', expanded: false }); + const sort = pane.addFolder({ title: 'Sort', expanded: false }); + const composite = pane.addFolder({ title: 'Composite', expanded: false }); + + importOptions + .addBinding(params, 'splatPackType', { + label: 'Pack type', + options: { + compressed: SplatPackType.Compressed, + superCompressed: SplatPackType.SuperCompressed, + sog: SplatPackType.Sog, + }, + }) + .on('change', markCustomPreset); + importOptions + .addBinding(params, 'maxSh', { label: 'Max SH', min: 0, max: 3, step: 1 }) + .on('change', handleSplattingParamChange); + importOptions + .addBinding(params, 'autoFreeResourceOnGpuPacked', { label: 'Auto free packed data' }) + .on('change', handleSplattingParamChange); + pack.addBinding(params, 'packHighPrecisionEnabled', { label: 'High precision' }).on( + 'change', + handleSplattingParamChange, + ); + pack.addBinding(params, 'precalculateEnabled', { label: 'Precalculate SH' }).on( + 'change', + handleSplattingParamChange, + ); + pack.addBinding(params, 'cameraRelativeEnabled', { label: 'Camera relative' }).on( + 'change', + handleSplattingParamChange, + ); + pack.addBinding(params, 'sortedLayoutEnabled', { label: 'Sorted layout' }).on('change', handleSplattingParamChange); + raster + .addBinding(params, 'normalizedFalloff', { label: 'Normalized falloff' }) + .on('change', handleSplattingParamChange); + raster + .addBinding(params, 'preBlurAmount', { label: 'Pre blur', min: 0, max: 1, step: 0.1 }) + .on('change', handleSplattingParamChange); + raster + .addBinding(params, 'blurAmount', { label: 'Blur', min: 0, max: 1, step: 0.1 }) + .on('change', handleSplattingParamChange); + raster + .addBinding(params, 'focalAdjustment', { label: 'Focal adjustment', min: 0.5, max: 2, step: 0.1 }) + .on('change', handleSplattingParamChange); + raster + .addBinding(params, 'detailCullingThreshold', { label: 'Detail culling', min: 0, max: 8, step: 1 }) + .on('change', handleSplattingParamChange); + raster + .addBinding(params, 'maxPixelRadius', { label: 'Max pixel radius', min: 1, max: 1024, step: 1 }) + .on('change', handleSplattingParamChange); + raster + .addBinding(params, 'maxStdDev', { label: 'Max std dev', min: 5, max: 8, step: 1 }) + .on('change', handleSplattingParamChange); + sort.addBinding(params, 'minIntervalMs', { label: 'Min duration', min: 0, max: 160, step: 16 }).on( + 'change', + handleSplattingParamChange, + ); + sort.addBinding(params, 'sortSplatDistance', { label: 'Splat distance', min: 0, max: 1, step: 0.01 }).on( + 'change', + handleSplattingParamChange, + ); + sort.addBinding(params, 'sortSplatCoorient', { label: 'Splat coorient', min: 0, max: 1, step: 0.01 }).on( + 'change', + handleSplattingParamChange, + ); + sort.addBinding(params, 'sortCameraDistance', { label: 'Camera distance', min: 0, max: 1, step: 0.01 }).on( + 'change', + handleSplattingParamChange, + ); + sort.addBinding(params, 'sortCameraCoorient', { label: 'Camera coorient', min: 0, max: 1, step: 0.01 }).on( + 'change', + handleSplattingParamChange, + ); + sort.addBinding(params, 'sortHighPrecisionEnabled', { label: 'High precision' }).on( + 'change', + handleSplattingParamChange, + ); + composite + .addBinding(params, 'renderAttachHighPrecisionEnabled', { label: 'High precision attach' }) + .on('change', handleSplattingParamChange); + + const toneMapping = pane.addFolder({ title: 'Tone Mapping', expanded: false }); + toneMapping.addBinding(params, 'toneMappingEnabled', { label: 'Enabled' }).on('change', onChange); + toneMapping + .addBinding(params, 'toneMapping', { + label: 'Curve', + options: { + Linear: ToneMapping.Linear, + Reinhard: ToneMapping.Reinhard, + ACES: ToneMapping.ACES, + ACESFilmic: ToneMapping.ACESFilmic, + Neutral: ToneMapping.Neutral, + }, + }) + .on('change', onChange); + toneMapping + .addBinding(params, 'exposure', { label: 'Exposure', min: 0.1, max: 4, step: 0.1 }) + .on('change', onChange); + + const highlight = pane.addFolder({ title: 'Highlight Kernel', expanded: false }); + highlight.addBinding(params, 'highlightEnabled', { label: 'Enabled' }).on('change', onChange); + highlight.addBinding(params, 'highlightSize', { label: 'Size', min: 1, max: 10, step: 1 }).on('change', onChange); + highlight.addBinding(params, 'highlightColor', { label: 'Color' }).on('change', onChange); + + const lod = pane.addFolder({ title: 'LOD Config', expanded: false }); + lod.addBinding(params, 'lodMinLevel', { label: 'Min level', min: 0, max: 4, step: 1 }).on('change', onChange); + lod.addBinding(params, 'lodMaxBudget', { label: 'Max budget', min: 1_000_000, max: 40_000_000, step: 100_000 }).on( + 'change', + onChange, + ); + lod.addBinding(params, 'lodBackgroundPenalty', { label: 'Background penalty', min: 0, max: 1, step: 0.1 }).on( + 'change', + onChange, + ); + lod.addBinding(params, 'lodOutsidePenalty', { label: 'Outside penalty', min: 0, max: 1, step: 0.1 }).on( + 'change', + onChange, + ); + lod.addBinding(params, 'lodBehindPenalty', { label: 'Behind penalty', min: 0, max: 1, step: 0.1 }).on( + 'change', + onChange, + ); + lod.addBinding(params, 'lodBehindTolerance', { label: 'Behind tolerance', min: -1, max: 1, step: 0.1 }).on( + 'change', + onChange, + ); + lod.addBinding(params, 'lodBehindDistanceTolerance', { label: 'Behind distance', min: 0, max: 10, step: 1 }).on( + 'change', + onChange, + ); + lod.addBinding(params, 'lodHysteresisTicks', { label: 'Hysteresis', min: 1, max: 10, step: 1 }).on( + 'change', + onChange, + ); + lod.addBinding(params, 'lodSchedulerParallelCounts', { label: 'Parallel', min: 1, max: 16, step: 1 }).on( + 'change', + onChange, + ); + lod.addBinding(params, 'lodSchedulerExistingTaskLimit', { label: 'Task limit', min: 1, max: 128, step: 1 }).on( + 'change', + onChange, + ); + lod.addBinding(params, 'lodSchedulerMinDuration', { label: 'Min duration', min: 16, max: 1600, step: 16 }).on( + 'change', + onChange, + ); + + return { + dispose() { + pane.dispose(); + }, + refreshProgrammatic, + }; +} + +function addViewerConfigTip(target: { element: HTMLElement }, text: string) { + target.element.title = text; +} + +function applyViewerConfig(viewer: Viewer, params: ViewerParams) { + setViewerConfig(viewer, { + pixelRatio: params.pixelRatio / window.devicePixelRatio, + pipeline: { + Background: { + background: { + active: BackgroundMode.BasicBackground, + basic: { color: new Color(0, 0, 0), alpha: 1 }, + }, + ground: { enabled: false }, + }, + Splatting: { + enabled: true, + pack: { + highPrecisionEnabled: params.packHighPrecisionEnabled, + precalculateEnabled: params.precalculateEnabled, + cameraRelativeEnabled: params.cameraRelativeEnabled, + sortedLayoutEnabled: params.sortedLayoutEnabled, + }, + raster: { + normalizedFalloff: params.normalizedFalloff, + preBlurAmount: params.preBlurAmount, + blurAmount: params.blurAmount, + focalAdjustment: params.focalAdjustment, + detailCullingThreshold: params.detailCullingThreshold, + maxPixelRadius: params.maxPixelRadius, + maxStdDev: globalThis.Math.sqrt(params.maxStdDev), + }, + sort: { + highPrecisionEnabled: params.sortHighPrecisionEnabled, + minIntervalMs: params.minIntervalMs, + sortSplatDistance: params.sortSplatDistance, + sortSplatCoorient: params.sortSplatCoorient, + sortCameraDistance: params.sortCameraDistance, + sortCameraCoorient: params.sortCameraCoorient, + }, + composite: { + enabled: params.renderAttachHighPrecisionEnabled, + highPrecisionEnabled: params.renderAttachHighPrecisionEnabled, + }, + toneMapping: { + enabled: params.toneMappingEnabled, + toneMapping: params.toneMapping, + exposure: params.exposure, + }, + highlightKernel: { + enabled: params.highlightEnabled, + size: params.highlightSize, + color: colorToNumber(params.highlightColor), + }, + }, + TAA: { enabled: false }, + }, + }); +} + +function getLodConfig(params: ViewerParams) { + return { + minLevel: params.lodMinLevel, + maxBudget: params.lodMaxBudget, + backgroundPenalty: params.lodBackgroundPenalty, + outsidePenalty: params.lodOutsidePenalty, + behindPenalty: params.lodBehindPenalty, + behindTolerance: params.lodBehindTolerance, + behindDistanceTolerance: params.lodBehindDistanceTolerance, + hysteresisTicks: params.lodHysteresisTicks, + schedulerParallelCounts: params.lodSchedulerParallelCounts, + schedulerExistingTaskLimit: params.lodSchedulerExistingTaskLimit, + schedulerMinDuration: params.lodSchedulerMinDuration, + }; +} + +function getInitialLodLevel(meta: LodMeta, params: ViewerParams) { + const levels = typeof meta.levels === 'number' ? meta.levels : 1; + return globalThis.Math.min(globalThis.Math.max(0, levels - 1), globalThis.Math.max(4, params.lodMinLevel)); +} + +function isLodMeta(value: unknown): value is LodMeta { + if (!value || typeof value !== 'object') { + return false; + } + + const meta = value as Record; + return ( + meta.magicCode === LOD_MAGIC_CODE && + meta.type === 'lod-splat' && + typeof meta.counts === 'number' && + typeof meta.levels === 'number' && + Array.isArray(meta.files) + ); +} + +async function readJsonIfNeeded(source: Source, signal: AbortSignal) { + if (!source.name.toLowerCase().endsWith('.json')) { + return undefined; + } + + const text = + source.kind === 'url' + ? await abortable(fetchText(source.url, signal), signal) + : await abortable(source.file.text(), signal); + + return JSON.parse(text) as unknown; +} + +async function fetchText(url: string, signal: AbortSignal) { + const response = await fetch(url, { signal }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`); + } + + return response.text(); +} + +async function getDetectionProbe(source: Source, json: unknown) { + if (json !== undefined) { + return new TextEncoder().encode(JSON.stringify(json)); + } + + if (source.kind === 'file') { + return new Uint8Array(await source.file.slice(0, 4096).arrayBuffer()); + } + + return new Uint8Array(); +} + +function renderFileList(container: HTMLElement, records: FileRecord[], config: ViewerPageConfig) { + container.replaceChildren(); + + if (records.length === 0) { + const item = document.createElement('li'); + item.className = 'viewer-empty-file'; + item.textContent = config.labels.emptyFiles; + container.append(item); + return; + } + + for (const record of records) { + const item = document.createElement('li'); + item.className = 'viewer-file-item'; + item.dataset.state = record.status; + + const header = document.createElement('div'); + header.className = 'viewer-file-item-header'; + + const info = document.createElement('div'); + info.className = 'viewer-file-info'; + + const title = document.createElement('strong'); + title.textContent = record.name; + + const details = document.createElement('span'); + details.textContent = `${record.format} / ${record.size}`; + info.append(title, details); + + const state = document.createElement('span'); + state.className = 'viewer-file-state'; + state.dataset.state = record.status; + state.setAttribute('aria-label', record.error ?? record.status); + state.title = record.error ?? record.status; + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'viewer-file-delete'; + deleteButton.dataset.fileDelete = ''; + deleteButton.setAttribute('aria-label', `${config.labels.removeFile}: ${record.name}`); + deleteButton.title = config.labels.removeFile; + + const deleteIcon = createTrashIcon(); + deleteButton.append(deleteIcon); + header.append(info, state, deleteButton); + + item.append(header); + container.append(item); + } +} + +function readLeftRailCollapsed() { + return readRailCollapsed(LEFT_RAIL_COLLAPSED_STORAGE_KEY); +} + +function readRightRailCollapsed() { + return readRailCollapsed(RIGHT_RAIL_COLLAPSED_STORAGE_KEY); +} + +function readRailCollapsed(storageKey: string) { + if (isCompactViewerLayout()) { + return true; + } + + try { + const stored = localStorage.getItem(storageKey); + if (stored !== null) { + return stored === 'true'; + } + } catch { + // Fall through to the responsive default when storage is unavailable. + } + + return false; +} + +function createTrashIcon() { + const namespace = 'http://www.w3.org/2000/svg'; + const icon = document.createElementNS(namespace, 'svg'); + icon.setAttribute('class', 'viewer-delete-icon'); + icon.setAttribute('viewBox', '0 0 24 24'); + icon.setAttribute('aria-hidden', 'true'); + + for (const attributes of [ + { d: 'M3 6h18' }, + { d: 'M8 6V4h8v2' }, + { d: 'M6 6l1 15h10l1-15' }, + { d: 'M10 11v6' }, + { d: 'M14 11v6' }, + ]) { + const path = document.createElementNS(namespace, 'path'); + path.setAttribute('d', attributes.d); + icon.append(path); + } + + return icon; +} + +function writeLeftRailCollapsed(collapsed: boolean) { + writeRailCollapsed(LEFT_RAIL_COLLAPSED_STORAGE_KEY, collapsed); +} + +function writeRightRailCollapsed(collapsed: boolean) { + writeRailCollapsed(RIGHT_RAIL_COLLAPSED_STORAGE_KEY, collapsed); +} + +function writeRailCollapsed(storageKey: string, collapsed: boolean) { + try { + localStorage.setItem(storageKey, String(collapsed)); + } catch { + // Persisting the panel state is optional; the viewer should still run. + } +} + +function isCompactViewerLayout() { + return window.matchMedia('(max-width: 720px)').matches; +} + +function createFileRecord(source: Source): FileRecord { + return { + name: source.name, + format: getInitialFormat(source.name), + size: source.size === undefined ? '-' : formatBytes(source.size), + status: 'loading', + }; +} + +function fileToSource(file: File): Source { + return { + kind: 'file', + file, + name: file.name, + size: file.size, + }; +} + +function urlToSource(url: string): Source { + return { + kind: 'url', + url, + name: getUrlFileName(url), + }; +} + +function parseUrls(value: string) { + return value + .split(/[\s,]+/u) + .map(item => item.trim()) + .filter(Boolean); +} + +function configureFileInputForPlatform(fileInput: HTMLInputElement) { + if (isIosFilePicker()) { + fileInput.removeAttribute('accept'); + } +} + +function isIosFilePicker() { + const userAgent = navigator.userAgent; + + return /iP(?:ad|hone|od)/u.test(userAgent) || (/Macintosh/u.test(userAgent) && navigator.maxTouchPoints > 1); +} + +function getSupportedFileExtensions(fileInput: HTMLInputElement) { + const extensions = fileInput.accept + .split(',') + .map(item => item.trim().toLowerCase()) + .filter(item => item.startsWith('.')); + + return extensions.length > 0 ? extensions : [...SUPPORTED_FILE_EXTENSIONS]; +} + +function validateImportSource(source: Source, supportedExtensions: readonly string[], unsupportedLabel: string) { + if (supportedExtensions.includes(getSourceExtension(source))) { + return; + } + + throw new Error(`${unsupportedLabel}: ${source.name} (${supportedExtensions.join(', ')})`); +} + +function getSourceExtension(source: Source) { + const name = source.name.trim().toLowerCase(); + const queryIndex = name.search(/[?#]/u); + const filename = queryIndex === -1 ? name : name.slice(0, queryIndex); + const extensionIndex = filename.lastIndexOf('.'); + + return extensionIndex === -1 ? '' : filename.slice(extensionIndex); +} + +function getUrlFileName(url: string) { + try { + const parsed = new URL(url); + const pathname = decodeURIComponent(parsed.pathname); + return pathname.split('/').filter(Boolean).pop() || parsed.hostname; + } catch { + return url; + } +} + +function getInitialFormat(name: string) { + const extension = name.split('.').pop(); + return extension ? extension.toUpperCase() : 'Unknown'; +} + +function getFileTypeLabel(type: SplatFileTypeValue) { + switch (type) { + case SplatFileType.PLY: + return 'PLY'; + case SplatFileType.SPZ: + return 'SPZ'; + case SplatFileType.SPLAT: + return 'SPLAT'; + case SplatFileType.KSPLAT: + return 'KSPLAT'; + case SplatFileType.LCC: + return 'LCC'; + case SplatFileType.SOG: + return 'SOG'; + case SplatFileType.ESZ: + return 'ESZ'; + default: + return 'Unknown'; + } +} + +function formatBytes(bytes: number) { + if (bytes <= 0) { + return '0 B'; + } + + const units = ['B', 'KB', 'MB', 'GB']; + const exponent = globalThis.Math.min( + globalThis.Math.floor(globalThis.Math.log(bytes) / globalThis.Math.log(1024)), + units.length - 1, + ); + const value = bytes / 1024 ** exponent; + return `${value.toFixed(value >= 10 || exponent === 0 ? 0 : 1)} ${units[exponent]}`; +} + +function colorToNumber(value: string) { + return Number.parseInt(value.replace(/^#/u, ''), 16); +} + +function resolveResourceUrl(url: string, baseUrl: string | undefined) { + try { + return new URL(url, baseUrl ?? window.location.href).toString(); + } catch { + return url; + } +} + +function serializeCamera(camera: PerspectiveCamera) { + return { + position: { x: camera.position.x, y: camera.position.y, z: camera.position.z }, + rotation: { x: camera.rotation.x, y: camera.rotation.y, z: camera.rotation.z, order: camera.rotation.order }, + up: { x: camera.up.x, y: camera.up.y, z: camera.up.z }, + fov: camera.fov, + near: camera.near, + far: camera.far, + }; +} + +function applyCameraState(camera: PerspectiveCamera, state: unknown) { + if (!state || typeof state !== 'object') { + throw new Error('Invalid camera state.'); + } + + const value = state as ReturnType; + camera.position.set(value.position.x, value.position.y, value.position.z); + camera.rotation.order = value.rotation.order; + camera.rotation.set(value.rotation.x, value.rotation.y, value.rotation.z); + camera.up.set(value.up.x, value.up.y, value.up.z); + camera.fov = value.fov; + camera.near = value.near; + camera.far = value.far; + camera.updateProjectionMatrix(); +} + +function disposeSceneObject(object: Object3D | SplatObject) { + const disposable = object as Object3D & { + destroy?: () => void; + freeGPU?: () => void; + }; + + disposable.freeGPU?.(); + disposable.destroy?.(); +} + +function createFpsEstimator() { + let lastFrameTime = 0; + let lastDisplayTime = 0; + let smoothedFps = 0; + let displayedFps = 0; + + return () => { + const now = performance.now(); + + if (lastFrameTime === 0) { + lastFrameTime = now; + lastDisplayTime = now; + return displayedFps; + } + + const frameDelta = globalThis.Math.max(1, now - lastFrameTime); + const fps = 1000 / frameDelta; + lastFrameTime = now; + smoothedFps = smoothedFps === 0 ? fps : smoothedFps + (fps - smoothedFps) * FPS_SMOOTHING_FACTOR; + + if (now - lastDisplayTime >= FPS_DISPLAY_INTERVAL_MS) { + displayedFps = globalThis.Math.round(smoothedFps); + lastDisplayTime = now; + } + + return displayedFps; + }; +} + +function throwIfAborted(signal: AbortSignal) { + throwIfAbortedWithMessage(signal, VIEWER_ABORT_MESSAGE); +} + +function abortable(promise: Promise, signal: AbortSignal) { + return abortableWithMessage(promise, signal, VIEWER_ABORT_MESSAGE); +} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function query(root: HTMLElement, selector: string): T { + const element = root.querySelector(selector); + + if (!element) { + throw new Error(`Missing viewer element: ${selector}`); + } + + return element; +} diff --git a/website/src/client/workspace-fullscreen.ts b/website/src/client/workspace-fullscreen.ts new file mode 100644 index 0000000..5aacbec --- /dev/null +++ b/website/src/client/workspace-fullscreen.ts @@ -0,0 +1,323 @@ +export const WORKSPACE_FULLSCREEN_CHANGE_EVENT = 'aholo:workspace-fullscreenchange'; + +type WorkspaceFullscreenSource = 'query' | 'keyboard' | 'browser' | 'none'; + +interface WorkspaceFullscreenChangeDetail { + active: boolean; + source: WorkspaceFullscreenSource; +} + +interface WorkspaceFullscreenOptions { + onChange?: (active: boolean, detail: WorkspaceFullscreenChangeDetail) => void; +} + +interface WorkspaceFullscreenController { + dispose(): void; +} + +const fullscreenQueryParam = 'fullscreen'; +const viewQueryParam = 'view'; +const fullscreenViewValue = 'fullscreen'; +const keyboardEnterGraceMs = 900; +const keyboardExitSuppressMs = 900; +const fullscreenViewportTolerance = 12; +const browserChromeTolerance = 24; +const browserFullscreenPollMs = 250; +const truthyValues = new Set(['1', 'true', 'yes', 'on']); +const falsyValues = new Set(['0', 'false', 'no', 'off']); + +export function mountWorkspaceFullscreenMode(options: WorkspaceFullscreenOptions = {}): WorkspaceFullscreenController { + let queryMode = readQueryMode(); + let keyboardRequested = false; + let observedBrowserFullscreen = false; + let keyboardEnterDeadline = 0; + let browserSuppressUntil = 0; + let active = false; + let source: WorkspaceFullscreenSource = 'none'; + let frameId: number | undefined; + let stateTimer: number | undefined; + let browserPollTimer: number | undefined; + let disposed = false; + + sync(); + + window.addEventListener('keydown', handleKeydown, true); + window.addEventListener('resize', scheduleSync); + window.visualViewport?.addEventListener('resize', scheduleSync); + document.addEventListener('fullscreenchange', scheduleSync); + window.addEventListener('popstate', handlePopState); + + return { + dispose() { + if (disposed) { + return; + } + + disposed = true; + + if (frameId !== undefined) { + window.cancelAnimationFrame(frameId); + frameId = undefined; + } + + clearStateTimer(); + clearBrowserPollTimer(); + + window.removeEventListener('keydown', handleKeydown, true); + window.removeEventListener('resize', scheduleSync); + window.visualViewport?.removeEventListener('resize', scheduleSync); + document.removeEventListener('fullscreenchange', scheduleSync); + window.removeEventListener('popstate', handlePopState); + }, + }; + + function handleKeydown(event: KeyboardEvent) { + if (event.key !== 'F11' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + + if (queryMode !== undefined) { + return; + } + + const now = performance.now(); + + if (active) { + keyboardRequested = false; + observedBrowserFullscreen = false; + keyboardEnterDeadline = 0; + browserSuppressUntil = now + keyboardExitSuppressMs; + scheduleStateSync(keyboardExitSuppressMs); + } else { + keyboardRequested = true; + observedBrowserFullscreen = false; + keyboardEnterDeadline = now + keyboardEnterGraceMs; + browserSuppressUntil = 0; + scheduleStateSync(keyboardEnterGraceMs); + } + + sync(); + } + + function handlePopState() { + queryMode = readQueryMode(); + sync(); + } + + function scheduleSync() { + if (frameId !== undefined) { + return; + } + + frameId = window.requestAnimationFrame(() => { + frameId = undefined; + sync(); + }); + } + + function scheduleStateSync(delay: number) { + if (stateTimer !== undefined) { + clearStateTimer(); + } + + stateTimer = window.setTimeout( + () => { + stateTimer = undefined; + sync(); + }, + Math.max(0, delay), + ); + } + + function clearStateTimer() { + if (stateTimer === undefined) { + return; + } + + window.clearTimeout(stateTimer); + stateTimer = undefined; + } + + function syncBrowserPollTimer() { + if (active && source === 'browser') { + if (browserPollTimer === undefined) { + browserPollTimer = window.setInterval(sync, browserFullscreenPollMs); + } + return; + } + + clearBrowserPollTimer(); + } + + function clearBrowserPollTimer() { + if (browserPollTimer === undefined) { + return; + } + + window.clearInterval(browserPollTimer); + browserPollTimer = undefined; + } + + function sync() { + const nextState = getNextState(); + + if (active === nextState.active && source === nextState.source) { + syncDataset(nextState.active); + syncBrowserPollTimer(); + return; + } + + active = nextState.active; + source = nextState.source; + syncDataset(nextState.active); + syncBrowserPollTimer(); + + const detail = { + active: nextState.active, + source: nextState.source, + }; + window.dispatchEvent( + new CustomEvent(WORKSPACE_FULLSCREEN_CHANGE_EVENT, { + detail, + }), + ); + options.onChange?.(nextState.active, detail); + } + + function getNextState(): WorkspaceFullscreenChangeDetail { + if (queryMode !== undefined) { + keyboardRequested = false; + observedBrowserFullscreen = false; + browserSuppressUntil = 0; + clearStateTimer(); + + return { + active: queryMode, + source: queryMode ? 'query' : 'none', + }; + } + + const now = performance.now(); + const browserFullscreen = document.fullscreenElement !== null || isBrowserFullscreenViewport(); + const browserSuppressed = browserSuppressUntil > now; + + if (browserFullscreen) { + observedBrowserFullscreen = true; + } + + if (browserFullscreen && !browserSuppressed) { + keyboardRequested = false; + keyboardEnterDeadline = 0; + clearStateTimer(); + + return { + active: true, + source: 'browser', + }; + } + + if (browserSuppressed) { + scheduleStateSync(browserSuppressUntil - now); + } else if (browserSuppressUntil > 0) { + browserSuppressUntil = 0; + } + + if (keyboardRequested) { + if (observedBrowserFullscreen && !browserFullscreen) { + keyboardRequested = false; + observedBrowserFullscreen = false; + keyboardEnterDeadline = 0; + clearStateTimer(); + + return { + active: false, + source: 'none', + }; + } + + if (keyboardEnterDeadline > now) { + scheduleStateSync(keyboardEnterDeadline - now); + + return { + active: true, + source: 'keyboard', + }; + } + + keyboardRequested = false; + observedBrowserFullscreen = false; + keyboardEnterDeadline = 0; + clearStateTimer(); + } + + return { + active: false, + source: 'none', + }; + } +} + +function readQueryMode() { + const params = new URLSearchParams(window.location.search); + const value = params.get(fullscreenQueryParam); + + if (value !== null) { + const normalized = value.trim().toLowerCase(); + + if (truthyValues.has(normalized) || normalized === '') { + return true; + } + + if (falsyValues.has(normalized)) { + return false; + } + } + + return params.get(viewQueryParam)?.trim().toLowerCase() === fullscreenViewValue ? true : undefined; +} + +function syncDataset(active: boolean) { + const root = document.documentElement; + const body = document.body; + + if (active) { + root.dataset.workspaceFullscreen = 'true'; + if (body) { + body.dataset.workspaceFullscreen = 'true'; + } + return; + } + + delete root.dataset.workspaceFullscreen; + if (body) { + delete body.dataset.workspaceFullscreen; + } +} + +function isBrowserFullscreenViewport() { + if (!canInferBrowserFullscreenViewport()) { + return false; + } + + const { screen } = window; + const targetWidth = Math.max(screen.width, screen.availWidth); + const targetHeight = Math.max(screen.height, screen.availHeight); + + if (!targetWidth || !targetHeight || !window.outerWidth || !window.outerHeight) { + return false; + } + + const fillsScreen = + window.innerWidth >= targetWidth - fullscreenViewportTolerance && + window.innerHeight >= targetHeight - fullscreenViewportTolerance; + const browserChromeHidden = + Math.abs(window.outerWidth - window.innerWidth) <= browserChromeTolerance && + Math.abs(window.outerHeight - window.innerHeight) <= browserChromeTolerance; + + return fillsScreen && browserChromeHidden; +} + +function canInferBrowserFullscreenViewport() { + // Mobile browsers often report a screen-filling viewport during normal browsing. + return !window.matchMedia('(max-width: 900px), (hover: none), (pointer: coarse)').matches; +} diff --git a/website/src/components/ExamplePreview.astro b/website/src/components/ExamplePreview.astro new file mode 100644 index 0000000..718037d --- /dev/null +++ b/website/src/components/ExamplePreview.astro @@ -0,0 +1,67 @@ +--- +import InteractionGuide from './InteractionGuide.astro'; +import type { Locale } from '../i18n/locales.js'; + +interface Props { + accent: string; + configLabel: string; + code: string; + errorLabel: string; + lang: Locale; + loadingLabel: string; + renderer: { + antialiasing: boolean; + pixelRatio?: number; + }; + showInteractionGuide?: boolean; + title: string; +} + +const { + accent, + configLabel, + code, + errorLabel, + lang, + loadingLabel, + renderer, + showInteractionGuide = true, + title, +} = Astro.props; +const configJson = JSON.stringify({ + accent, + configLabel, + code, + errorLabel, + loadingLabel, + renderer, +}).replace(/ + +
{loadingLabel}
+ + {showInteractionGuide && } + diff --git a/website/src/components/ExampleRail.astro b/website/src/components/ExampleRail.astro new file mode 100644 index 0000000..fd8c964 --- /dev/null +++ b/website/src/components/ExampleRail.astro @@ -0,0 +1,49 @@ +--- +import type { Locale } from '../i18n/locales.js'; +import { localizedPath } from '../i18n/routes.js'; +import { examples } from '../utils/examples.js'; + +interface Props { + currentSlug: string; + label: string; + lang: Locale; + navId?: string; + tagsLabel: string; + variant?: 'workspace' | 'drawer'; +} + +const { currentSlug, label, lang, navId, tagsLabel, variant = 'workspace' } = Astro.props; +--- + + diff --git a/website/src/components/InteractionGuide.astro b/website/src/components/InteractionGuide.astro new file mode 100644 index 0000000..759a28f --- /dev/null +++ b/website/src/components/InteractionGuide.astro @@ -0,0 +1,302 @@ +--- +import type { Locale } from '../i18n/locales.js'; + +interface Props { + activeWhen?: 'interactive'; + lang: Locale; +} + +const { activeWhen, lang } = Astro.props; +const labels = + lang === 'zh-CN' + ? { + move: '移动', + riseDescend: '升降', + zoom: '缩放', + rotate: '旋转', + orbit: '环绕', + roll: '滚转', + pan: '平移', + fullscreen: '全屏', + } + : { + move: 'Move', + riseDescend: 'Rise / Descend', + zoom: 'Zoom', + rotate: 'Rotate', + orbit: 'Orbit', + roll: 'Roll', + pan: 'Pan', + fullscreen: 'Fullscreen', + }; +--- + + + + + + diff --git a/website/src/components/PlaygroundShell.astro b/website/src/components/PlaygroundShell.astro new file mode 100644 index 0000000..3c00505 --- /dev/null +++ b/website/src/components/PlaygroundShell.astro @@ -0,0 +1,201 @@ +--- +import InteractionGuide from './InteractionGuide.astro'; +import { dictionary } from '../i18n/dictionary.js'; +import type { Locale } from '../i18n/locales.js'; +// oxlint-disable-next-line import/default +import cameraControlTypes from '../client/camera-control.ts?raw'; +// oxlint-disable-next-line import/default +import renderRuntimeTypes from '../client/render-runtime.d.ts?raw'; +// oxlint-disable-next-line import/default +import rendererTypes from '../../node_modules/@manycore/aholo-viewer/dist/index.d.ts?raw'; +import { getPlaygroundPresets } from '../utils/examples.js'; + +interface Props { + lang: Locale; +} + +const { lang } = Astro.props; +const t = dictionary[lang]; +const rendererEntryTypes = 'export * from "./dist/index";\n'; +const tweakpaneTypes = ` +declare module "tweakpane" { + export interface PaneConfig { + container?: HTMLElement; + expanded?: boolean; + title?: string; + } + + export interface TpChangeEvent { + value: T; + last: boolean; + } + + export interface BladeApi { + hidden: boolean; + disabled: boolean; + dispose(): void; + } + + export interface InputBindingApi extends BladeApi { + on(eventName: "change", handler: (event: TpChangeEvent) => void): this; + refresh(): void; + } + + export interface ButtonApi extends BladeApi { + on(eventName: "click", handler: () => void): this; + } + + export class Pane { + expanded: boolean; + constructor(config?: PaneConfig); + addBinding( + target: T, + key: K, + options?: Record + ): InputBindingApi; + addButton(options: Record): ButtonApi; + addFolder(options: Record): Pane; + refresh(): void; + dispose(): void; + } + + export const VERSION: unknown; +} +`; +const editorLoadingLabel = lang === 'zh-CN' ? '正在加载编辑器...' : 'Loading editor...'; +const playgroundActionsLabel = lang === 'zh-CN' ? 'Playground 操作' : 'Playground actions'; +const resizePanelsLabel = lang === 'zh-CN' ? '调整编辑器和预览宽度' : 'Resize editor and preview'; +const expandEditorLabel = lang === 'zh-CN' ? '展开编辑器' : 'Expand editor'; +const configLabel = lang === 'zh-CN' ? '配置' : 'Config'; +const configJson = JSON.stringify({ + presets: getPlaygroundPresets(lang), + labels: t.playground, + common: t.common, + typeDefinitions: [ + { + path: 'file:///node_modules/@manycore/aholo-viewer/index.d.ts', + content: rendererEntryTypes, + }, + { + path: 'file:///node_modules/@manycore/aholo-viewer/dist/index.d.ts', + content: rendererTypes, + }, + { + path: 'file:///src/client/render-runtime.d.ts', + content: renderRuntimeTypes, + }, + { + path: 'file:///src/client/camera-control.ts', + content: cameraControlTypes, + }, + { + path: 'file:///node_modules/tweakpane/index.d.ts', + content: tweakpaneTypes, + }, + ], +}).replace(/ +
+
+
{editorLoadingLabel}
+
+
+ + +
+ +
+
+ + + +
+ + + + + +
+
+ + diff --git a/website/src/components/ViewerShell.astro b/website/src/components/ViewerShell.astro new file mode 100644 index 0000000..bc610e9 --- /dev/null +++ b/website/src/components/ViewerShell.astro @@ -0,0 +1,272 @@ +--- +import { dictionary } from '../i18n/dictionary.js'; +import type { Locale } from '../i18n/locales.js'; + +interface Props { + lang: Locale; +} + +const { lang } = Astro.props; +const t = dictionary[lang]; +const labels = + lang === 'zh-CN' + ? { + import: '导入', + selectFiles: '选择文件', + fileFormatHint: 'PLY / SPZ / SPLAT / KSPLAT / LCC / SOG / ESZ / JSON', + urlLabel: 'URL', + urlPlaceholder: '粘贴 .ply / .spz / .sog / .esz / .json 地址', + loadUrl: '加载 URL', + loadClipboard: '从剪贴板加载', + removeFile: '删除文件', + files: '导入文件', + emptyFiles: '尚未导入文件', + camera: '相机', + coordSystem: '坐标系', + far: '远裁剪面', + useOrbit: '环绕', + copyCamera: '复制状态', + pasteCamera: '粘贴状态', + resetCamera: '重置视角', + settings: '渲染参数', + preset: '预设', + presetCustom: '自定义', + presetMaxQuality: '极限效果', + presetQualityFirst: '效果优先', + presetBalanced: '均衡模式', + presetPerformanceFirst: '性能优先', + presetExtremePerformance0: '极限性能 0', + presetExtremePerformance1: '极限性能 1', + presetPackTypeTip: '预设会影响 Pack type,请在导入前选择。', + packTypeTip: '导入前生效', + statusReady: '', + statusLoading: '正在加载', + statusError: '加载失败', + unsupportedFileType: '不支持的文件类型', + statsFps: 'FPS', + dropOverlayTitle: '释放文件以导入项目', + dropOverlayHint: '支持 .ply / .spz / .sog / .esz / .json 等格式', + collapseLeft: '收起左侧设置', + expandLeft: '展开左侧设置', + collapseRight: '收起渲染参数', + expandRight: '展开渲染参数', + } + : { + import: 'Import', + selectFiles: 'Choose Files', + fileFormatHint: 'PLY / SPZ / SPLAT / KSPLAT / LCC / SOG / ESZ / JSON', + urlLabel: 'URL', + urlPlaceholder: 'Paste .ply / .spz / .sog / .esz / .json URLs', + loadUrl: 'Load URL', + loadClipboard: 'Load Clipboard', + removeFile: 'Remove File', + files: 'Imported Files', + emptyFiles: 'No imported files', + camera: 'Camera', + coordSystem: 'Coordinate System', + far: 'Far Plane', + useOrbit: 'Orbit', + copyCamera: 'Copy State', + pasteCamera: 'Paste State', + resetCamera: 'Reset View', + settings: 'Render Settings', + preset: 'Preset', + presetCustom: 'Custom', + presetMaxQuality: 'Max Quality', + presetQualityFirst: 'Quality First', + presetBalanced: 'Balanced', + presetPerformanceFirst: 'Performance First', + presetExtremePerformance0: 'Extreme Performance 0', + presetExtremePerformance1: 'Extreme Performance 1', + presetPackTypeTip: 'Presets affect Pack type. Choose before import.', + packTypeTip: 'Before import', + statusReady: '', + statusLoading: 'Loading', + statusError: 'Load failed', + unsupportedFileType: 'Unsupported file type', + statsFps: 'FPS', + dropOverlayTitle: 'Release files to import', + dropOverlayHint: 'Supports .ply / .spz / .sog / .esz / .json and more', + collapseLeft: 'Collapse left settings', + expandLeft: 'Expand left settings', + collapseRight: 'Collapse render settings', + expandRight: 'Expand render settings', + }; +const configJson = JSON.stringify({ labels }).replace(/ + (() => { + const compact = window.matchMedia('(max-width: 720px)').matches; + const readCollapsed = storageKey => { + if (compact) { + return true; + } + + try { + const stored = localStorage.getItem(storageKey); + if (stored !== null) { + return stored === 'true'; + } + } catch { + // Keep the responsive default when storage is unavailable. + } + + return false; + }; + + document.documentElement.dataset.viewerLeftCollapsed = String(readCollapsed('aholo:viewer:left-collapsed')); + document.documentElement.dataset.viewerRightCollapsed = String(readCollapsed('aholo:viewer:right-collapsed')); + })(); + + +
+
+
+
+ 0 +
+ + + + + + + + + + + + diff --git a/website/src/components/docs/DocPager.astro b/website/src/components/docs/DocPager.astro new file mode 100644 index 0000000..b5d355c --- /dev/null +++ b/website/src/components/docs/DocPager.astro @@ -0,0 +1,36 @@ +--- +interface PagerItem { + href: string; + label: string; +} + +interface Props { + previous?: PagerItem; + next?: PagerItem; +} + +const { previous, next } = Astro.props; +--- + + diff --git a/website/src/components/docs/DocToc.astro b/website/src/components/docs/DocToc.astro new file mode 100644 index 0000000..7e904cc --- /dev/null +++ b/website/src/components/docs/DocToc.astro @@ -0,0 +1,30 @@ +--- +interface DocTocHeading { + depth: number; + slug: string; + text: string; +} + +interface Props { + headings: readonly DocTocHeading[]; + label: string; +} + +const { headings, label } = Astro.props; +const items = headings.filter(heading => heading.depth === 2 || heading.depth === 3); +--- + +{ + items.length > 0 && ( + + ) +} diff --git a/website/src/components/site/Breadcrumbs.astro b/website/src/components/site/Breadcrumbs.astro new file mode 100644 index 0000000..9be2480 --- /dev/null +++ b/website/src/components/site/Breadcrumbs.astro @@ -0,0 +1,21 @@ +--- +interface Props { + items: Array<{ + href: string; + label: string; + }>; +} + +const { items } = Astro.props; +--- + + diff --git a/website/src/components/site/Header.astro b/website/src/components/site/Header.astro new file mode 100644 index 0000000..f9c2fe0 --- /dev/null +++ b/website/src/components/site/Header.astro @@ -0,0 +1,203 @@ +--- +import LanguageSwitch from './LanguageSwitch.astro'; +import ThemeToggle from './ThemeToggle.astro'; +import { dictionary } from '../../i18n/dictionary.js'; +import { localizedPath } from '../../i18n/routes.js'; +import type { Locale } from '../../i18n/locales.js'; +import { navItems } from '../../utils/navigation.js'; + +interface Props { + lang: Locale; + currentPath: string; +} + +const { lang, currentPath } = Astro.props; +const t = dictionary[lang]; +const githubUrl = 'https://github.com/manycoretech/aholo-viewer'; +const hasDrawerContext = Astro.slots.has('default'); +const drawerLabel = lang === 'zh-CN' ? '移动端导航' : 'Mobile navigation'; +const openMenuLabel = lang === 'zh-CN' ? '打开导航' : 'Open navigation'; +const closeMenuLabel = lang === 'zh-CN' ? '关闭导航' : 'Close navigation'; +const navLinks = navItems.map(item => { + const href = localizedPath(lang, item.href); + return { + href, + active: item.href === '/' ? currentPath === href : currentPath.startsWith(href), + label: t.nav[item.key], + }; +}); +--- + + + + + + diff --git a/website/src/components/site/LanguageSwitch.astro b/website/src/components/site/LanguageSwitch.astro new file mode 100644 index 0000000..44b02e4 --- /dev/null +++ b/website/src/components/site/LanguageSwitch.astro @@ -0,0 +1,42 @@ +--- +import { localeStorageKey, locales, type Locale } from '../../i18n/locales.js'; +import { swapLocale } from '../../i18n/routes.js'; + +interface Props { + lang: Locale; + currentPath: string; +} + +const { lang, currentPath } = Astro.props; +const target = locales.find(locale => locale.code !== lang) ?? locales[0]; +const targetHref = swapLocale(currentPath, lang, target.code); +--- + + + {target.shortLabel} + + + diff --git a/website/src/components/site/Sidebar.astro b/website/src/components/site/Sidebar.astro new file mode 100644 index 0000000..f138e1c --- /dev/null +++ b/website/src/components/site/Sidebar.astro @@ -0,0 +1,263 @@ +--- +interface SidebarItemInput { + href?: string; + label: string; + description?: string; + searchText?: string; + items?: SidebarItemInput[]; +} + +interface Props { + currentPath: string; + items: SidebarItemInput[]; + search?: { + label: string; + empty: string; + }; +} + +const { currentPath, items, search } = Astro.props; +const hasActiveContent = Astro.slots.has('activeContent'); + +const getSearchText = (item: { label: string; description?: string; searchText?: string }) => + [item.label, item.description, item.searchText].filter(Boolean).join(' '); +--- + + + + diff --git a/website/src/components/site/ThemeToggle.astro b/website/src/components/site/ThemeToggle.astro new file mode 100644 index 0000000..67a77fe --- /dev/null +++ b/website/src/components/site/ThemeToggle.astro @@ -0,0 +1,15 @@ + + + diff --git a/website/src/content.config.ts b/website/src/content.config.ts new file mode 100644 index 0000000..4d3f717 --- /dev/null +++ b/website/src/content.config.ts @@ -0,0 +1,75 @@ +import { defineCollection } from 'astro:content'; +import { glob } from 'astro/loaders'; +import { z } from 'astro/zod'; + +const localizedText = z.object({ + 'zh-CN': z.string().min(1), + 'en-US': z.string().min(1), +}); + +const renderSessionRendererOptions = z + .object({ + antialiasing: z.boolean().default(false), + pixelRatio: z.number().positive().optional(), + }) + .default({ + antialiasing: false, + }); + +const exampleSurface = z.enum(['examples', 'playground', 'home']); +const exampleSurfaces = z + .array(exampleSurface) + .min(1) + .refine(surfaces => new Set(surfaces).size === surfaces.length, { + message: 'Example surfaces must be unique.', + }) + .or(z.literal('none')) + .default(['examples', 'playground']); +const hexColor = z.string().regex(/^#[\da-f]{6}$/i); +const exampleCoverImageUrl = z + .string() + .trim() + .min(1) + .refine( + value => { + if (value.startsWith('/')) { + return true; + } + + try { + new URL(value); + return true; + } catch { + return false; + } + }, + { + message: 'Example coverImageUrl must be an absolute URL or a site-root path.', + }, + ); + +const exampleSchema = z.object({ + order: z.number().int().nonnegative(), + surfaces: exampleSurfaces, + tags: z.array(z.string().min(1)).min(1), + accent: hexColor, + coverImageUrl: exampleCoverImageUrl, + title: localizedText, + renderer: renderSessionRendererOptions, + showInteractionGuide: z.boolean().default(true), +}); + +export type ExampleData = z.infer; +export type ExampleSurface = z.infer; + +const examples = defineCollection({ + loader: glob({ + base: './src/content/examples', + pattern: '*.json', + }), + schema: exampleSchema, +}); + +export const collections = { + examples, +}; diff --git a/website/src/content/examples/3d-buffer-geometry.json b/website/src/content/examples/3d-buffer-geometry.json new file mode 100644 index 0000000..a9961ac --- /dev/null +++ b/website/src/content/examples/3d-buffer-geometry.json @@ -0,0 +1,15 @@ +{ + "order": 6, + "tags": ["3d", "mesh", "buffer-geometry", "texture"], + "accent": "#34d399", + "coverImageUrl": "https://holo-cos.aholo3d.cn/aholo-opensource/page/example-cover/3d-buffer-geometry.2ae2f964.png", + "renderer": { + "antialiasing": true, + "pixelRatio": 1 + }, + "showInteractionGuide": false, + "title": { + "zh-CN": "3D 几何", + "en-US": "3D Buffer Geometry" + } +} diff --git a/website/src/content/examples/3d-buffer-geometry.ts b/website/src/content/examples/3d-buffer-geometry.ts new file mode 100644 index 0000000..7edf99f --- /dev/null +++ b/website/src/content/examples/3d-buffer-geometry.ts @@ -0,0 +1,85 @@ +import type { RenderRuntime } from '../../client/render-runtime.js'; +import { + AmbientLight, + BufferAttribute, + BufferGeometry, + downloadTexture, + Mesh, + MeshPhongMaterial, + PerspectiveCamera, + Side, + Vector3, +} from '@manycore/aholo-viewer'; + +const TEXTURE_BASE_URL = 'https://holo-cos.aholo3d.cn/aholo-opensource/page/texture/'; +const TEXTURE_URLS = [`${TEXTURE_BASE_URL}grasslight-big.e8cc62ea.jpg`, `${TEXTURE_BASE_URL}lavatile.52fa1c03.jpg`]; + +export default async function runner({ renderer, loading, signal }: RenderRuntime) { + const { scene, viewer } = renderer; + const camera = viewer.getCamera() as PerspectiveCamera; + camera.up.set(0, 0, 1); + camera.position.set(10, 10, 10); + camera.lookAt(new Vector3(0, 0, 0)); + scene.add(new AmbientLight(0xffffff, 1)); + + loading.show('Loading textures'); + const textures = await Promise.all(TEXTURE_URLS.map(url => downloadTexture(url))); + if (signal.aborted) { + throw new DOMException('The 3D buffer geometry sample load was aborted.', 'AbortError'); + } + + const meshes = textures.map( + texture => new Mesh(createGeometry(), new MeshPhongMaterial({ texture, side: Side.DoubleSide })), + ); + scene.add(meshes); + + loading.hide(); + renderer.frame(({ time }) => { + const elapsedSec = time * 0.001; + meshes.forEach(mesh => { + mesh.rotation.z = elapsedSec / 4; + }); + return true; + }); + + return () => { + scene.removeAllChildren(); + meshes.forEach(m => m.freeAllGpuResourceOwned()); + }; +} + +const TRIANGLE_COUNT = 4000; +const FIELD_SIZE = 10; +const TRIANGLE_SIZE = 0.4; +const randomOffset = (span: number) => (Math.random() - 0.5) * span; +function createGeometry() { + const positions = new Float32Array(TRIANGLE_COUNT * 9); + const normals = new Float32Array(TRIANGLE_COUNT * 9); + const uvs = new Float32Array(TRIANGLE_COUNT * 6); + for (let i = 0; i < TRIANGLE_COUNT; i++) { + const cx = randomOffset(FIELD_SIZE); + const cy = randomOffset(FIELD_SIZE); + const cz = randomOffset(FIELD_SIZE); + for (let j = 0; j < 3; j++) { + const x = cx + randomOffset(TRIANGLE_SIZE); + const y = cy + randomOffset(TRIANGLE_SIZE); + const z = cz + randomOffset(TRIANGLE_SIZE); + positions[i * 9 + j * 3 + 0] = x; + positions[i * 9 + j * 3 + 1] = y; + positions[i * 9 + j * 3 + 2] = z; + normals[i * 9 + j * 3 + 0] = x; + normals[i * 9 + j * 3 + 1] = y; + normals[i * 9 + j * 3 + 2] = z; + } + + uvs[i * 6 + 2] = 0.5; + uvs[i * 6 + 3] = 1; + uvs[i * 6 + 4] = 1; + } + + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new BufferAttribute(positions, 3)); + geometry.setAttribute('normal', new BufferAttribute(normals, 3)); + geometry.setAttribute('uv', new BufferAttribute(uvs, 2)); + return geometry; +} diff --git a/website/src/content/examples/3d-point-light.json b/website/src/content/examples/3d-point-light.json new file mode 100644 index 0000000..8fc3150 --- /dev/null +++ b/website/src/content/examples/3d-point-light.json @@ -0,0 +1,15 @@ +{ + "order": 7, + "tags": ["3d", "lighting", "point-light"], + "accent": "#fbbf24", + "coverImageUrl": "https://holo-cos.aholo3d.cn/aholo-opensource/page/example-cover/3d-point-light.28df11a2.png", + "renderer": { + "antialiasing": true, + "pixelRatio": 1 + }, + "showInteractionGuide": false, + "title": { + "zh-CN": "3D 点光源", + "en-US": "3D Point Light" + } +} diff --git a/website/src/content/examples/3d-point-light.ts b/website/src/content/examples/3d-point-light.ts new file mode 100644 index 0000000..c8f32a0 --- /dev/null +++ b/website/src/content/examples/3d-point-light.ts @@ -0,0 +1,190 @@ +import { + BufferAttribute, + BufferGeometry, + DirectionalLight, + downloadTexture, + Mesh, + MeshBasicMaterial, + MeshPhongMaterial, + PerspectiveCamera, + PointLight, + Vector3, +} from '@manycore/aholo-viewer'; +import type { RenderRuntime } from '../../client/render-runtime.js'; + +const FLOOR_TEXTURE_URL = 'https://holo-cos.aholo3d.cn/aholo-opensource/page/texture/disturb.76f1cbca.jpg'; + +export default async function runner({ renderer, control, loading, signal }: RenderRuntime) { + const { scene, viewer } = renderer; + const camera = viewer.getCamera() as PerspectiveCamera; + camera.near = 100; + camera.far = 1_000_000; + camera.updateProjectionMatrix(); + + loading.show('Loading floor texture'); + const floorTexture = await downloadTexture(FLOOR_TEXTURE_URL); + throwIfAborted(signal); + + const directionalLight = new DirectionalLight(0xffffff, 0.2); + const floorGeometry = createPlaneGeometry(FLOOR_SIZE); + const floorMaterial = new MeshPhongMaterial({ + specular: 0xffffff, + shininess: 500, + texture: floorTexture, + }); + + const floor = new Mesh(floorGeometry, floorMaterial); + floor.position.set(0, 0, -2000); + + const bulbGeometry = createSphereGeometry(300); + const bulbMaterials = LIGHT_COLORS.map(color => new MeshBasicMaterial({ color })); + const pointLights = LIGHT_COLORS.map((color, i) => { + const light = new PointLight(color, 2.5, 30000, 1); + light.add(new Mesh(bulbGeometry, bulbMaterials[i]!)); + return light; + }); + + const geometry = createTorusGeometry(500, 150); + const material = new MeshPhongMaterial({ specular: 0xffffff, shininess: 100 }); + const meshList = Array.from({ length: OBJECT_COUNT }, () => { + const mesh = new Mesh(geometry, material); + mesh.position.set(randSigned(100000), randSigned(100000), rand(2000, 20000)); + mesh.rotation.set(rand(0, Math.PI), rand(0, Math.PI), 0); + return mesh; + }); + + const sceneObjects = [directionalLight, floor, ...pointLights, ...meshList]; + scene.add(sceneObjects); + + camera.up.set(0, 0, 1); + camera.position.set(0, 0, 100000); + camera.lookAt(new Vector3(20000, 20000, 0)); + camera.updateMatrixWorld(true); + control.setOptions({ enabled: false }); + + loading.hide(); + + /** Same driver as other previews: frame callback returns whether to render; adapter calls `viewer.render()` once. */ + renderer.frame(({ time }) => { + const elapsedSec = time * 0.001; + pointLights.forEach((light, i) => { + const phase = (i / pointLights.length) * Math.PI; + const radius = Math.sin(elapsedSec * 0.2 + phase) * 80000; + light.position.set(Math.cos(phase) * radius, Math.sin(phase) * radius, 0); + }); + return true; + }); + + renderer.render(); + + return () => { + scene.removeObjects(sceneObjects); + pointLights.flatMap(light => light.removeAllChildren()).forEach(child => child.destroy()); + + for (const object of sceneObjects) object.destroy(); + for (const resource of [ + floorGeometry, + floorMaterial, + bulbGeometry, + ...bulbMaterials, + geometry, + material, + floorTexture, + ]) { + resource.destroy(); + } + }; +} + +function throwIfAborted(signal: AbortSignal) { + if (signal.aborted) { + throw new DOMException('The 3D point light sample load was aborted.', 'AbortError'); + } +} + +const TAU = Math.PI * 2; +const FLOOR_SIZE = 200000; +const OBJECT_COUNT = 3000; +const LIGHT_COLORS = [0xff0040, 0x0040ff, 0x80ff80, 0xffaa00, 0x00ffaa, 0xff1100]; + +function rand(min: number, max: number) { + return min + Math.random() * (max - min); +} + +const randSigned = (limit: number) => rand(-limit, limit); + +function createSurfaceGeometry( + widthSegments: number, + heightSegments: number, + sample: (u: number, v: number) => [number, number, number, number, number, number], +): BufferGeometry { + const positions: number[] = []; + const normals: number[] = []; + const uvs: number[] = []; + const indices: number[] = []; + + for (let y = 0; y <= heightSegments; y++) { + for (let x = 0; x <= widthSegments; x++) { + const u = x / widthSegments; + const v = y / heightSegments; + const [px, py, pz, nx, ny, nz] = sample(u, v); + positions.push(px, py, pz); + normals.push(nx, ny, nz); + uvs.push(u, 1 - v); + } + } + + const row = widthSegments + 1; + for (let y = 0; y < heightSegments; y++) { + for (let x = 0; x < widthSegments; x++) { + const a = x + row * y; + const b = a + row; + indices.push(a, b, a + 1, b, b + 1, a + 1); + } + } + + const geometry = new BufferGeometry(); + geometry.setIndex(indices); + geometry.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3)); + geometry.setAttribute('normal', new BufferAttribute(new Float32Array(normals), 3)); + geometry.setAttribute('uv', new BufferAttribute(new Float32Array(uvs), 2)); + geometry.computeBoundingSphere(); + return geometry; +} + +function createPlaneGeometry(size: number) { + return createSurfaceGeometry(1, 1, (u, v) => [(u - 0.5) * size, (0.5 - v) * size, 0, 0, 0, 1]); +} + +function createSphereGeometry(radius: number, segments = 18) { + return createSurfaceGeometry(segments, segments, (u, v) => { + const phi = u * TAU; + const theta = v * Math.PI; + const sinTheta = Math.sin(theta); + const x = -Math.cos(phi) * sinTheta; + const y = Math.cos(theta); + const z = Math.sin(phi) * sinTheta; + return [x * radius, y * radius, z * radius, x, y, z]; + }); +} + +function createTorusGeometry(radius: number, tube: number, segments = 18) { + return createSurfaceGeometry(segments, segments, (u, v) => { + const phi = u * TAU; + const theta = -v * TAU; + const cosPhi = Math.cos(phi); + const sinPhi = Math.sin(phi); + const cosTheta = Math.cos(theta); + const sinTheta = Math.sin(theta); + const nx = cosTheta * cosPhi; + const ny = cosTheta * sinPhi; + return [ + (radius + tube * cosTheta) * cosPhi, + (radius + tube * cosTheta) * sinPhi, + tube * sinTheta, + nx, + ny, + sinTheta, + ]; + }); +} diff --git a/website/src/content/examples/home-interaction.json b/website/src/content/examples/home-interaction.json new file mode 100644 index 0000000..041f45a --- /dev/null +++ b/website/src/content/examples/home-interaction.json @@ -0,0 +1,15 @@ +{ + "order": 99, + "surfaces": ["home"], + "tags": ["3dgs", "splatting", "lod", "streaming"], + "accent": "#38bdf8", + "coverImageUrl": "/home/feature-lod.svg", + "renderer": { + "antialiasing": false, + "pixelRatio": 1 + }, + "title": { + "zh-CN": "主页交互", + "en-US": "Home Interaction" + } +} diff --git a/website/src/content/examples/home-interaction.ts b/website/src/content/examples/home-interaction.ts new file mode 100644 index 0000000..718fd73 --- /dev/null +++ b/website/src/content/examples/home-interaction.ts @@ -0,0 +1,152 @@ +import { createViewerContext, setViewerConfig, SplatLoader, SplatUtils, ToneMapping } from '@manycore/aholo-viewer'; +import type { RenderRuntime, RuntimeIndexedDBStorage } from '../../client/render-runtime.js'; + +const HOME_INTERACTION_ENTER_EVENT = 'aholo:home-interaction-enter'; + +export default async function runner({ renderer, control, loading, indexedDB, signal }: RenderRuntime) { + const { scene, viewer } = renderer; + setViewerConfig(viewer, { + pipeline: { + Splatting: { + enabled: true, + toneMapping: { + enabled: true, + toneMapping: ToneMapping.Neutral, + }, + }, + }, + }); + const camera = viewer.getCamera(); + camera.up.set(0, -1, 0); + camera.position.set(-0.9800918057099783, -1.7506846691679372, 2.292388933466888); + camera.rotation.set(0.11785010330530897, -0.030190695395364366, -3.133801078676436); + control.setOptions({ enabled: true }); + + loading.show('Loading home interaction'); + const envData = await loadResource( + 'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/misc/home-interaction-env.73524ff2.sog', + indexedDB, + ); + const env = await SplatUtils.createSplat(envData); + scene.add(env); + const meta = await loadLodMeta( + 'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/huochezhan/chunk-lod/6b077ba2/lod-meta.json', + signal, + ); + throwIfAborted(signal); + + const splat = new SplatUtils.LodSplat( + meta, + { + minLevel: meta.levels - 1, + maxBudget: 2000000, + schedulerParallelCounts: 99999, + }, + createViewerContext(viewer), + url => loadResource(url, indexedDB), + ); + scene.add(splat.container); + + splat.tick(viewer.getCamera()); + splat.start(); + renderer.render(); + await splat.onFinishSchedule(); + if (signal.aborted) { + splat.destroy(); + throwIfAborted(signal); + } + loading.hide(); + + // Resolve the session once the preview is ready. Upgrading to the full-detail + // walkthrough continues in the background when the user enters the space. + void upgradeOnEnter(); + + return () => splat.destroy(); + + async function upgradeOnEnter() { + try { + await waitForHomeInteraction(signal); + } catch { + return; + } + if (signal.aborted) { + return; + } + + splat.setConfig({ + minLevel: 0, + schedulerParallelCounts: 4, + }); + renderer.frame(({ delta }) => { + // Outside the immersive mode the camera is frozen: skip the per-frame + // control damping and LOD scheduling work entirely. + if (!document.documentElement.classList.contains('home-interactive')) { + return false; + } + const updated = control.update(delta); + splat.tick(viewer.getCamera()); + return updated; + }); + } +} + +function waitForHomeInteraction(signal: AbortSignal) { + if (document.documentElement.classList.contains('home-interactive')) { + return Promise.resolve(); + } + if (signal.aborted) { + return Promise.reject(new DOMException('The home interaction load was aborted.', 'AbortError')); + } + + return new Promise((resolve, reject) => { + const cleanup = () => { + document.removeEventListener(HOME_INTERACTION_ENTER_EVENT, handleEnter); + signal.removeEventListener('abort', handleAbort); + }; + const handleEnter = () => { + cleanup(); + resolve(); + }; + const handleAbort = () => { + cleanup(); + reject(new DOMException('The home interaction load was aborted.', 'AbortError')); + }; + + document.addEventListener(HOME_INTERACTION_ENTER_EVENT, handleEnter, { once: true }); + signal.addEventListener('abort', handleAbort, { once: true }); + }); +} + +async function loadLodMeta(url: string, signal: AbortSignal) { + const response = await fetch(url, { signal }); + const content = await response.json(); + if (!(content.magicCode === 2500660 && content.type === 'lod-splat')) { + throw new Error('LOD metadata is not a supported lod-splat manifest.'); + } + return content as SplatUtils.LodMeta; +} + +type ISplatData = ReturnType; +async function loadResource(url: string, db: RuntimeIndexedDBStorage) { + const cached = await db.get(url, { version: 0 }); + if (cached) { + const data = new SplatLoader.CompressedSplatData(); + data.deserialize(cached); + return data; + } + + const fileType = SplatLoader.detectSplatFileType(url, new Uint8Array()); + if (fileType === undefined) { + throw new Error(`Unsupported LOD splat resource: ${url}`); + } + + const data = await SplatLoader.parseSplatData(fileType, url, SplatLoader.SplatPackType.Compressed); + await db.set(url, data.serialize(), { version: 0 }); + return data; +} + +function throwIfAborted(signal: AbortSignal) { + if (signal.aborted) { + throw new DOMException('The home LOD stream sample load was aborted.', 'AbortError'); + } +} diff --git a/website/src/content/examples/splatting-basic.json b/website/src/content/examples/splatting-basic.json new file mode 100644 index 0000000..bef9649 --- /dev/null +++ b/website/src/content/examples/splatting-basic.json @@ -0,0 +1,14 @@ +{ + "order": 0, + "tags": ["3dgs", "splatting"], + "accent": "#5eead4", + "coverImageUrl": "https://holo-cos.aholo3d.cn/aholo-opensource/page/example-cover/splatting-basic.9c5fc85d.png", + "renderer": { + "antialiasing": false, + "pixelRatio": 1 + }, + "title": { + "zh-CN": "Splatting 基础", + "en-US": "Splatting Basic" + } +} diff --git a/website/src/content/examples/splatting-basic.ts b/website/src/content/examples/splatting-basic.ts new file mode 100644 index 0000000..3a49f15 --- /dev/null +++ b/website/src/content/examples/splatting-basic.ts @@ -0,0 +1,136 @@ +import { type Viewer, setViewerConfig, ToneMapping, SplatLoader, SplatUtils, Vector3 } from '@manycore/aholo-viewer'; +import type { RenderRuntime, RuntimeConfigPanel, RuntimeIndexedDBStorage } from '../../client/render-runtime.js'; + +export default async function runner({ renderer, control, loading, configPanel, indexedDB, signal }: RenderRuntime) { + const { scene, viewer } = renderer; + setViewerConfig(viewer, { + pipeline: { + Splatting: { enabled: true }, + }, + }); + initConfigPanel(viewer, configPanel); + + const camera = viewer.getCamera(); + camera.up.set(0, -1, 0); + camera.position.set(-1.5, -0.5, 0); + camera.lookAt(new Vector3(0, 0, 0)); + control.setOptions({ enabled: true, useOrbit: true }); + + loading.show('Loading splat data'); + const data = await loadSplatData( + 'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/bear/bear.3d71a266.sog', + indexedDB, + ); + throwIfAborted(signal); + const splat = await SplatUtils.createSplat(data); + throwIfAborted(signal); + scene.add(splat); + + renderer.frame(({ delta }) => control.update(delta)); + + renderer.render(); + loading.hide(); + + return () => { + scene?.remove(splat); + splat?.destroy(); + }; +} + +function throwIfAborted(signal: AbortSignal) { + if (signal.aborted) { + throw new DOMException('The splatting basic sample load was aborted.', 'AbortError'); + } +} + +function initConfigPanel(viewer: Viewer, configPanel: RuntimeConfigPanel) { + const params = { + precalculateEnabled: true, + normalizedFalloff: false, + preBlurAmount: 0.3, + blurAmount: 0, + focalAdjustment: 2, + detailCullingThreshold: 1, + toneMappingEnabled: false, + toneMapping: ToneMapping.Neutral, + exposure: 1, + }; + + const applyConfig = () => { + setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + precalculateEnabled: params.precalculateEnabled, + }, + raster: { + normalizedFalloff: params.normalizedFalloff, + preBlurAmount: params.preBlurAmount, + blurAmount: params.blurAmount, + focalAdjustment: params.focalAdjustment, + detailCullingThreshold: params.detailCullingThreshold, + }, + toneMapping: { + enabled: params.toneMappingEnabled, + toneMapping: params.toneMapping, + exposure: params.exposure, + }, + }, + }, + }); + }; + + applyConfig(); + + const pane = configPanel.createPane({ title: 'Splatting' }); + pane.addBinding(params, 'precalculateEnabled', { label: 'Precalculate SH' }).on('change', applyConfig); + pane.addBinding(params, 'normalizedFalloff', { label: 'Normalized falloff' }).on('change', applyConfig); + pane.addBinding(params, 'preBlurAmount', { label: 'Pre blur', max: 1, min: 0, step: 0.1 }).on( + 'change', + applyConfig, + ); + pane.addBinding(params, 'blurAmount', { label: 'Blur', max: 1, min: 0, step: 0.1 }).on('change', applyConfig); + pane.addBinding(params, 'focalAdjustment', { label: 'Focal adjustment', max: 2, min: 0.5, step: 0.1 }).on( + 'change', + applyConfig, + ); + pane.addBinding(params, 'detailCullingThreshold', { label: 'Detail culling', max: 4, min: 0, step: 1 }).on( + 'change', + applyConfig, + ); + + const toneMapping = pane.addFolder({ title: 'Tone Mapping', expanded: false }); + toneMapping.addBinding(params, 'toneMappingEnabled', { label: 'Enabled' }).on('change', applyConfig); + toneMapping + .addBinding(params, 'toneMapping', { + label: 'Curve', + options: { + Linear: ToneMapping.Linear, + Reinhard: ToneMapping.Reinhard, + ACES: ToneMapping.ACES, + ACESFilmic: ToneMapping.ACESFilmic, + Neutral: ToneMapping.Neutral, + }, + }) + .on('change', applyConfig); + toneMapping + .addBinding(params, 'exposure', { label: 'Exposure', max: 2, min: 0.1, step: 0.1 }) + .on('change', applyConfig); +} + +type ISplatData = ReturnType; + +const CACHE_KEY = 'splatting-basic:bear'; +const CACHE_VERSION = 0; +async function loadSplatData(url: string, db: RuntimeIndexedDBStorage) { + const cached = await db.get(CACHE_KEY, { version: CACHE_VERSION }); + if (cached) { + const data = new SplatLoader.SuperCompressedSplatData(); + data.deserialize(cached); + return data; + } + + const data = await SplatLoader.parseSplatData(SplatLoader.SplatFileType.SOG, url); + await db.set(CACHE_KEY, data.serialize(), { version: CACHE_VERSION }); + return data; +} diff --git a/website/src/content/examples/splatting-lod-stream.json b/website/src/content/examples/splatting-lod-stream.json new file mode 100644 index 0000000..e128212 --- /dev/null +++ b/website/src/content/examples/splatting-lod-stream.json @@ -0,0 +1,14 @@ +{ + "order": 1, + "tags": ["3dgs", "splatting", "lod", "streaming"], + "accent": "#38bdf8", + "coverImageUrl": "https://holo-cos.aholo3d.cn/aholo-opensource/page/example-cover/splatting-lod-stream.6b6d466a.png", + "renderer": { + "antialiasing": false, + "pixelRatio": 1 + }, + "title": { + "zh-CN": "Splatting LOD 流式加载", + "en-US": "Splatting LOD Stream" + } +} diff --git a/website/src/content/examples/splatting-lod-stream.ts b/website/src/content/examples/splatting-lod-stream.ts new file mode 100644 index 0000000..3c042a3 --- /dev/null +++ b/website/src/content/examples/splatting-lod-stream.ts @@ -0,0 +1,262 @@ +import { + createViewerContext, + setViewerConfig, + SplatLoader, + SplatUtils, + ToneMapping, + type Viewer, +} from '@manycore/aholo-viewer'; +import type { RenderRuntime, RuntimeConfigPanel, RuntimeIndexedDBStorage } from '../../client/render-runtime.js'; + +const LodConfig: Omit & { + highPrecisionEnabled: boolean; + maxBudgetMillions: number; +} = { + minLevel: 0, + maxBudget: 6000000, + backgroundPenalty: 0.5, + outsidePenalty: 0.4, + behindPenalty: 0.1, + behindTolerance: -0.2, + behindDistanceTolerance: 2, + hysteresisTicks: 4, + schedulerParallelCounts: 4, + schedulerExistingTaskLimit: 64, + schedulerMinDuration: 160, + highPrecisionEnabled: false, + maxBudgetMillions: 6, +}; + +export default async function runner({ renderer, control, loading, configPanel, indexedDB, signal }: RenderRuntime) { + const { scene, viewer } = renderer; + setViewerConfig(viewer, { + pipeline: { + Splatting: { + enabled: true, + pack: { + precalculateEnabled: false, + }, + toneMapping: { + enabled: true, + toneMapping: ToneMapping.Neutral, + }, + }, + }, + }); + const camera = viewer.getCamera(); + camera.up.set(0, -1, 0); + camera.position.set(-0.9800918057099783, -1.7506846691679372, 2.292388933466888); + camera.rotation.set(0.11785010330530897, -0.030190695395364366, -3.133801078676436); + control.setOptions({ enabled: true }); + + loading.show('Streaming initial LOD'); + const envData = await loadResource( + 'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/misc/home-interaction-env.73524ff2.sog', + indexedDB, + ); + const env = await SplatUtils.createSplat(envData); + scene.add(env); + + const meta = await loadLodMeta( + 'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/huochezhan/chunk-lod/6b077ba2/lod-meta.json', + signal, + ); + throwIfAborted(signal); + + const splat = new SplatUtils.LodSplat( + meta, + { + ...LodConfig, + minLevel: meta.levels - 1, + schedulerParallelCounts: 99999, + }, + createViewerContext(viewer), + url => loadResource(url, indexedDB), + ); + scene.add(splat.container); + + splat.tick(camera); + splat.start(); + await splat.onFinishSchedule(); + if (signal.aborted) { + splat.destroy(); + throwIfAborted(signal); + } + loading.hide(); + + if (signal.aborted) { + splat.destroy(); + throwIfAborted(signal); + } + + initConfigPanel(splat, configPanel, viewer); + + renderer.frame(({ delta }) => { + const updated = control.update(delta); + splat.tick(viewer.getCamera()); + return updated; + }); + + return () => splat.destroy(); +} + +function initConfigPanel(splat: SplatUtils.LodSplat, configPanel: RuntimeConfigPanel, viewer: Viewer) { + const applyConfig = () => { + if (LodConfig.highPrecisionEnabled) { + setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + highPrecisionEnabled: true, + cameraRelativeEnabled: false, + }, + }, + }, + }); + } else { + setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + highPrecisionEnabled: false, + cameraRelativeEnabled: true, + }, + }, + }, + }); + } + LodConfig.maxBudget = LodConfig.maxBudgetMillions * 1_000_000; + splat.setConfig(LodConfig); + }; + applyConfig(); + + const panel = configPanel.createPane({ title: 'Splatting LOD Stream' }); + panel.addBinding(LodConfig, 'highPrecisionEnabled', { label: 'High precision' }).on('change', applyConfig); + const budget = panel.addFolder({ title: 'LOD Budget', expanded: true }); + budget + .addBinding(LodConfig, 'minLevel', { + label: 'Min level', + max: 4, + min: 0, + step: 1, + }) + .on('change', applyConfig); + budget + .addBinding(LodConfig, 'maxBudgetMillions', { + label: 'Max budget (M)', + max: 20, + min: 1, + step: 0.1, + }) + .on('change', applyConfig); + + const visibility = panel.addFolder({ title: 'Visibility Weights', expanded: false }); + visibility + .addBinding(LodConfig, 'backgroundPenalty', { + label: 'Background', + max: 1, + min: 0, + step: 0.05, + }) + .on('change', applyConfig); + visibility + .addBinding(LodConfig, 'outsidePenalty', { + label: 'Outside', + max: 1, + min: 0, + step: 0.05, + }) + .on('change', applyConfig); + visibility + .addBinding(LodConfig, 'behindPenalty', { + label: 'Behind', + max: 1, + min: 0, + step: 0.05, + }) + .on('change', applyConfig); + visibility + .addBinding(LodConfig, 'behindTolerance', { + label: 'Behind dot', + max: 0.5, + min: -1, + step: 0.05, + }) + .on('change', applyConfig); + visibility + .addBinding(LodConfig, 'behindDistanceTolerance', { + label: 'Behind dist', + max: 12, + min: 0, + step: 0.5, + }) + .on('change', applyConfig); + visibility + .addBinding(LodConfig, 'hysteresisTicks', { + label: 'Hysteresis', + max: 12, + min: 0, + step: 1, + }) + .on('change', applyConfig); + + const scheduler = panel.addFolder({ title: 'Streaming Scheduler', expanded: false }); + scheduler + .addBinding(LodConfig, 'schedulerParallelCounts', { + label: 'Parallel', + max: 16, + min: 1, + step: 1, + }) + .on('change', applyConfig); + scheduler + .addBinding(LodConfig, 'schedulerExistingTaskLimit', { + label: 'Cached tasks', + max: 256, + min: 1, + step: 1, + }) + .on('change', applyConfig); + scheduler + .addBinding(LodConfig, 'schedulerMinDuration', { + label: 'Min duration', + max: 500, + min: 0, + step: 20, + }) + .on('change', applyConfig); +} + +async function loadLodMeta(url: string, signal: AbortSignal) { + const response = await fetch(url, { signal }); + const content = await response.json(); + if (!(content.magicCode === 2500660 && content.type === 'lod-splat')) { + throw new Error('LOD metadata is not a supported lod-splat manifest.'); + } + return content as SplatUtils.LodMeta; +} + +type ISplatData = ReturnType; +async function loadResource(url: string, db: RuntimeIndexedDBStorage) { + const cached = await db.get(url, { version: 0 }); + if (cached) { + const data = new SplatLoader.CompressedSplatData(); + data.deserialize(cached); + return data; + } + + const fileType = SplatLoader.detectSplatFileType(url, new Uint8Array()); + if (fileType === undefined) { + throw new Error(`Unsupported LOD splat resource: ${url}`); + } + + const data = await SplatLoader.parseSplatData(fileType, url, SplatLoader.SplatPackType.Compressed); + await db.set(url, data.serialize(), { version: 0 }); + return data; +} + +function throwIfAborted(signal: AbortSignal) { + if (signal.aborted) { + throw new DOMException('The splatting LOD stream sample load was aborted.', 'AbortError'); + } +} diff --git a/website/src/content/examples/walk-demo.json b/website/src/content/examples/walk-demo.json new file mode 100644 index 0000000..76409a8 --- /dev/null +++ b/website/src/content/examples/walk-demo.json @@ -0,0 +1,15 @@ +{ + "order": 4, + "tags": ["walk", "3dgs", "first-person", "third-person"], + "accent": "#0ea5e9", + "coverImageUrl": "https://holo-cos.aholo3d.cn/aholo-opensource/page/example-cover/walk-demo.2a5373b0.png", + "renderer": { + "antialiasing": false, + "pixelRatio": 1 + }, + "showInteractionGuide": false, + "title": { + "zh-CN": "Splatting 行走模式", + "en-US": "Splatting Walk Mode" + } +} diff --git a/website/src/content/examples/walk-demo.ts b/website/src/content/examples/walk-demo.ts new file mode 100644 index 0000000..0b5b59d --- /dev/null +++ b/website/src/content/examples/walk-demo.ts @@ -0,0 +1,2257 @@ +/** + * @file Walk demo: splats + collision + ViewerWalkMode. + * + * Derived from SuperSplat Viewer (MIT) and adapted for Aholo Viewer, voxel collision, and the website runtime. + * + * Reading order for code display: + * 1. Voxel collision data and queries. + * 2. Capsule push-out and walk controller. + * 3. Character presentation. + * 4. Demo scene wiring, presets, and resource loading. + * + * Scene presets and spawn poses are defined near the bottom of the file. + */ +import type { RenderRuntime, RuntimeIndexedDBStorage } from '../../client/render-runtime.js'; +import { + AmbientLight, + Animation, + BackgroundMode, + DirectionalLight, + createViewerContext, + downloadTexture, + SplatLoader, + GLTFLoader, + Object3D, + PerspectiveCamera, + setViewerConfig, + SplatUtils, + Vector3, + Euler, + Box3, + Color, +} from '@manycore/aholo-viewer'; +import type { Scene3D, Viewer } from '@manycore/aholo-viewer'; + +const AnimationPlugin = Animation.AnimationPlugin; +const AnimationMixer = Animation.AnimationMixer; +const Skeleton = Animation.Skeleton; + +const { CompressedSplatData, parseSplatData, detectSplatFileType, SplatPackType } = SplatLoader; +type SerializedCompressedSplatData = Parameters['deserialize']>[0]; +const { createSplat, LodSplat } = SplatUtils; +const { loadGLTF } = GLTFLoader; + +type AnimationClip = Animation.AnimationClip; + +// ----------------------------------------------------------------------------- +// Voxel collision data and queries +// ----------------------------------------------------------------------------- + +const SOLID_LEAF_MARKER = 0xff000000 >>> 0; +const PENETRATION_EPSILON = 1e-4; +const MAX_RESOLVE_ITERATIONS = 4; + +/** + * Sparse voxel collision field. The walk controller only needs point occupancy, raycast, and capsule push-out. + */ +export class VoxelCollision { + private gridMinX: number; + private gridMinY: number; + private gridMinZ: number; + private voxelCountX: number; + private voxelCountY: number; + private voxelCountZ: number; + private voxelSize: number; + private leafSize: number; + private treeDepth: number; + private nodes: Uint32Array; + private leafData: Uint32Array; + private scratchPush: { x: number; y: number; z: number } = { x: 0, y: 0, z: 0 }; + private contactNormals: { x: number; y: number; z: number }[] = [ + { x: 0, y: 0, z: 0 }, + { x: 0, y: 0, z: 0 }, + { x: 0, y: 0, z: 0 }, + ]; + + constructor( + metadata: { + gridBounds: { min: number[]; max: number[] }; + voxelResolution: number; + leafSize: number; + treeDepth: number; + }, + nodes: Uint32Array, + leafData: Uint32Array, + ) { + this.gridMinX = metadata.gridBounds.min[0]; + this.gridMinY = metadata.gridBounds.min[1]; + this.gridMinZ = metadata.gridBounds.min[2]; + const voxelSize = metadata.voxelResolution; + this.voxelCountX = Math.round((metadata.gridBounds.max[0] - metadata.gridBounds.min[0]) / voxelSize); + this.voxelCountY = Math.round((metadata.gridBounds.max[1] - metadata.gridBounds.min[1]) / voxelSize); + this.voxelCountZ = Math.round((metadata.gridBounds.max[2] - metadata.gridBounds.min[2]) / voxelSize); + this.voxelSize = voxelSize; + this.leafSize = metadata.leafSize; + this.treeDepth = metadata.treeDepth; + this.nodes = nodes; + this.leafData = leafData; + } + + /** Fast point occupancy lookup. */ + isVoxelSolid(ix: number, iy: number, iz: number): boolean { + if ( + this.nodes.length === 0 || + ix < 0 || + iy < 0 || + iz < 0 || + ix >= this.voxelCountX || + iy >= this.voxelCountY || + iz >= this.voxelCountZ + ) { + return false; + } + const blockX = Math.floor(ix / this.leafSize); + const blockY = Math.floor(iy / this.leafSize); + const blockZ = Math.floor(iz / this.leafSize); + let nodeIndex = 0; + for (let level = this.treeDepth - 1; level >= 0; level--) { + const node = this.nodes[nodeIndex] >>> 0; + if (node === SOLID_LEAF_MARKER) { + return true; + } + const childMask = (node >>> 24) & 0xff; + if (childMask === 0) { + return this.checkLeafByIndex(node, ix, iy, iz); + } + const bitX = (blockX >>> level) & 1; + const bitY = (blockY >>> level) & 1; + const bitZ = (blockZ >>> level) & 1; + const octant = (bitZ << 2) | (bitY << 1) | bitX; + if ((childMask & (1 << octant)) === 0) { + return false; + } + const baseOffset = node & 0x00ffffff; + const prefix = (1 << octant) - 1; + nodeIndex = baseOffset + popcount(childMask & prefix); + } + const node = this.nodes[nodeIndex] >>> 0; + if (node === SOLID_LEAF_MARKER) { + return true; + } + return this.checkLeafByIndex(node, ix, iy, iz); + } + + /** Raycast through voxels for ground snaps and camera blocking checks. */ + queryRay( + ox: number, + oy: number, + oz: number, + dx: number, + dy: number, + dz: number, + maxDist: number, + ): { x: number; y: number; z: number } | null { + if (this.nodes.length === 0) { + return null; + } + const voxelSize = this.voxelSize; + const gMinX = this.gridMinX; + const gMinY = this.gridMinY; + const gMinZ = this.gridMinZ; + const gMaxX = gMinX + this.voxelCountX * voxelSize; + const gMaxY = gMinY + this.voxelCountY * voxelSize; + const gMaxZ = gMinZ + this.voxelCountZ * voxelSize; + const EPS = 1e-12; + + let tNear = 0; + let tFar = maxDist; + const slab = (o: number, d: number, min: number, max: number) => { + if (Math.abs(d) <= EPS) { + return o >= min && o < max; + } + let t1 = (min - o) / d; + let t2 = (max - o) / d; + if (t1 > t2) { + const tmp = t1; + t1 = t2; + t2 = tmp; + } + if (t1 > tNear) { + tNear = t1; + } + tFar = Math.min(tFar, t2); + return tNear <= tFar; + }; + if (!slab(ox, dx, gMinX, gMaxX) || !slab(oy, dy, gMinY, gMaxY) || !slab(oz, dz, gMinZ, gMaxZ)) { + return null; + } + const entryX = ox + dx * tNear; + const entryY = oy + dy * tNear; + const entryZ = oz + dz * tNear; + let ix = Math.max(0, Math.min(Math.floor((entryX - gMinX) / voxelSize), this.voxelCountX - 1)); + let iy = Math.max(0, Math.min(Math.floor((entryY - gMinY) / voxelSize), this.voxelCountY - 1)); + let iz = Math.max(0, Math.min(Math.floor((entryZ - gMinZ) / voxelSize), this.voxelCountZ - 1)); + + const stepX = dx > 0 ? 1 : dx < 0 ? -1 : 0; + const stepY = dy > 0 ? 1 : dy < 0 ? -1 : 0; + const stepZ = dz > 0 ? 1 : dz < 0 ? -1 : 0; + const invDx = Math.abs(dx) > EPS ? 1 / dx : 0; + const invDy = Math.abs(dy) > EPS ? 1 / dy : 0; + const invDz = Math.abs(dz) > EPS ? 1 / dz : 0; + let tMaxX = Math.abs(dx) > EPS ? (gMinX + (ix + (dx > 0 ? 1 : 0)) * voxelSize - ox) * invDx : Infinity; + let tMaxY = Math.abs(dy) > EPS ? (gMinY + (iy + (dy > 0 ? 1 : 0)) * voxelSize - oy) * invDy : Infinity; + let tMaxZ = Math.abs(dz) > EPS ? (gMinZ + (iz + (dz > 0 ? 1 : 0)) * voxelSize - oz) * invDz : Infinity; + const tDeltaX = Math.abs(dx) > EPS ? voxelSize * Math.abs(invDx) : Infinity; + const tDeltaY = Math.abs(dy) > EPS ? voxelSize * Math.abs(invDy) : Infinity; + const tDeltaZ = Math.abs(dz) > EPS ? voxelSize * Math.abs(invDz) : Infinity; + let currentT = tNear; + + const maxSteps = this.voxelCountX + this.voxelCountY + this.voxelCountZ; + for (let i = 0; i < maxSteps; i++) { + if (this.isVoxelSolid(ix, iy, iz)) { + return { x: ox + dx * currentT, y: oy + dy * currentT, z: oz + dz * currentT }; + } + if (tMaxX < tMaxY) { + if (tMaxX < tMaxZ) { + currentT = tMaxX; + ix += stepX; + tMaxX += tDeltaX; + } else { + currentT = tMaxZ; + iz += stepZ; + tMaxZ += tDeltaZ; + } + } else if (tMaxY < tMaxZ) { + currentT = tMaxY; + iy += stepY; + tMaxY += tDeltaY; + } else { + currentT = tMaxZ; + iz += stepZ; + tMaxZ += tDeltaZ; + } + if ( + ix < 0 || + iy < 0 || + iz < 0 || + ix >= this.voxelCountX || + iy >= this.voxelCountY || + iz >= this.voxelCountZ || + currentT > maxDist + ) { + return null; + } + } + return null; + } + + /** Resolve a vertical capsule out of solid voxels. */ + queryCapsule( + cx: number, + cy: number, + cz: number, + halfHeight: number, + radius: number, + out: { x: number; y: number; z: number }, + ): boolean { + return this.resolveIterative( + cx, + cy, + cz, + (rx, ry, rz, push) => this.resolveDeepestPenetrationCapsule(rx, ry, rz, halfHeight, radius, push), + out, + ); + } + + /** Read one packed leaf bit. */ + private checkLeafByIndex(node: number, ix: number, iy: number, iz: number) { + const leafDataIndex = node & 0x00ffffff; + const vx = ix & 3; + const vy = iy & 3; + const vz = iz & 3; + const bitIndex = vz * 16 + vy * 4 + vx; + if (bitIndex < 32) { + const lo = this.leafData[leafDataIndex * 2] >>> 0; + return ((lo >>> bitIndex) & 1) === 1; + } + const hi = this.leafData[leafDataIndex * 2 + 1] >>> 0; + return ((hi >>> (bitIndex - 32)) & 1) === 1; + } + + /** Find the strongest push needed to move a capsule out of nearby solid voxels. */ + private resolveDeepestPenetrationCapsule( + cx: number, + cy: number, + cz: number, + halfHeight: number, + radius: number, + out: { x: number; y: number; z: number }, + ): boolean { + const voxelSize = this.voxelSize; + const radiusSq = radius * radius; + const segBottomY = cy - halfHeight; + const segTopY = cy + halfHeight; + const ixMin = Math.floor((cx - radius - this.gridMinX) / voxelSize); + const iyMin = Math.floor((segBottomY - radius - this.gridMinY) / voxelSize); + const izMin = Math.floor((cz - radius - this.gridMinZ) / voxelSize); + const ixMax = Math.floor((cx + radius - this.gridMinX) / voxelSize); + const iyMax = Math.floor((segTopY + radius - this.gridMinY) / voxelSize); + const izMax = Math.floor((cz + radius - this.gridMinZ) / voxelSize); + let bestPushX = 0; + let bestPushY = 0; + let bestPushZ = 0; + let bestPen = PENETRATION_EPSILON; + let found = false; + + for (let iz = izMin; iz <= izMax; iz++) { + for (let iy = iyMin; iy <= iyMax; iy++) { + for (let ix = ixMin; ix <= ixMax; ix++) { + if (!this.isVoxelSolid(ix, iy, iz)) { + continue; + } + const vMinX = this.gridMinX + ix * voxelSize; + const vMinY = this.gridMinY + iy * voxelSize; + const vMinZ = this.gridMinZ + iz * voxelSize; + const vMaxX = vMinX + voxelSize; + const vMaxY = vMinY + voxelSize; + const vMaxZ = vMinZ + voxelSize; + let segY: number; + if (segTopY < vMinY) { + segY = segTopY; + } else if (segBottomY > vMaxY) { + segY = segBottomY; + } else { + segY = Math.max(segBottomY, Math.min(segTopY, (vMinY + vMaxY) * 0.5)); + } + const nearX = Math.max(vMinX, Math.min(cx, vMaxX)); + const nearY = Math.max(vMinY, Math.min(segY, vMaxY)); + const nearZ = Math.max(vMinZ, Math.min(cz, vMaxZ)); + const dx = cx - nearX; + const dy = segY - nearY; + const dz = cz - nearZ; + const distSq = dx * dx + dy * dy + dz * dz; + if (distSq >= radiusSq) { + continue; + } + let px = 0; + let py = 0; + let pz = 0; + let penetration: number; + if (distSq > 1e-12) { + const dist = Math.sqrt(distSq); + penetration = radius - dist; + const invDist = 1 / dist; + px = dx * invDist * penetration; + py = dy * invDist * penetration; + pz = dz * invDist * penetration; + } else { + const escapeX = Math.min(cx - vMinX, vMaxX - cx) + radius; + const escapeY = Math.min(segY - vMinY, vMaxY - segY) + radius; + const escapeZ = Math.min(cz - vMinZ, vMaxZ - cz) + radius; + if (escapeX <= escapeY && escapeX <= escapeZ) { + px = cx - vMinX < vMaxX - cx ? -escapeX : escapeX; + penetration = escapeX; + } else if (escapeY <= escapeZ) { + py = segY - vMinY < vMaxY - segY ? -escapeY : escapeY; + penetration = escapeY; + } else { + pz = cz - vMinZ < vMaxZ - cz ? -escapeZ : escapeZ; + penetration = escapeZ; + } + } + if (penetration > bestPen) { + bestPen = penetration; + bestPushX = px; + bestPushY = py; + bestPushZ = pz; + found = true; + } + } + } + } + if (found) { + out.x = bestPushX; + out.y = bestPushY; + out.z = bestPushZ; + } + return found; + } + + /** Apply a few push-out passes so corner collisions do not trap the capsule. */ + private resolveIterative( + cx: number, + cy: number, + cz: number, + findPenetration: (x: number, y: number, z: number, out: { x: number; y: number; z: number }) => boolean, + out: { x: number; y: number; z: number }, + ): boolean { + let resolvedX = cx; + let resolvedY = cy; + let resolvedZ = cz; + let totalPushX = 0; + let totalPushY = 0; + let totalPushZ = 0; + let hadCollision = false; + let numNormals = 0; + + for (let iter = 0; iter < MAX_RESOLVE_ITERATIONS; iter++) { + if (!findPenetration(resolvedX, resolvedY, resolvedZ, this.scratchPush)) { + break; + } + hadCollision = true; + let px = this.scratchPush.x; + let py = this.scratchPush.y; + let pz = this.scratchPush.z; + + for (let i = 0; i < numNormals; i++) { + const n = this.contactNormals[i]; + const dot = px * n.x + py * n.y + pz * n.z; + if (dot < 0) { + px -= dot * n.x; + py -= dot * n.y; + pz -= dot * n.z; + } + } + + const len = Math.sqrt( + this.scratchPush.x * this.scratchPush.x + + this.scratchPush.y * this.scratchPush.y + + this.scratchPush.z * this.scratchPush.z, + ); + if (len > PENETRATION_EPSILON && numNormals < 3) { + const invLen = 1 / len; + const n = this.contactNormals[numNormals]; + n.x = this.scratchPush.x * invLen; + n.y = this.scratchPush.y * invLen; + n.z = this.scratchPush.z * invLen; + numNormals++; + } + + resolvedX += px; + resolvedY += py; + resolvedZ += pz; + totalPushX += px; + totalPushY += py; + totalPushZ += pz; + } + + const totalPushSq = totalPushX * totalPushX + totalPushY * totalPushY + totalPushZ * totalPushZ; + const hasSignificantPush = hadCollision && totalPushSq > PENETRATION_EPSILON * PENETRATION_EPSILON; + if (hasSignificantPush) { + out.x = totalPushX; + out.y = totalPushY; + out.z = totalPushZ; + } + return hasSignificantPush; + } +} + +const popcount = (n: number) => { + n >>>= 0; + n -= (n >>> 1) & 0x55555555; + n = (n & 0x33333333) + ((n >>> 2) & 0x33333333); + return (((n + (n >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24; +}; + +// ----------------------------------------------------------------------------- +// Capsule collision and walk controller +// ----------------------------------------------------------------------------- + +const WALK_SIMULATION_STEP_SECONDS = 1 / 60; +const MAX_SUBSTEPS = 10; +const WALK_CAPSULE_HEIGHT = 1.5; +const WALK_CAPSULE_RADIUS = 0.12; +const WALK_HOVER_HEIGHT = 0.2; +const WALK_EYE_HEIGHT = WALK_CAPSULE_HEIGHT - 0.1 - WALK_HOVER_HEIGHT; +const WALK_GRAVITY = 9.8; +const THIRD_PERSON_MODEL_SCALE = 0.8; + +export interface ViewerWalkCharacterState { + position: InstanceType; + yaw: number; + speed: number; + walkSpeed: number; + verticalVelocity: number; + grounded: boolean; +} + +/** Walk controller: voxel collision in, camera and character state out. */ +export class ViewerWalkMode { + private collision: { + queryRay: (...args: number[]) => { x: number; y: number; z: number } | null; + queryCapsule: ( + cx: number, + cy: number, + cz: number, + halfHeight: number, + radius: number, + out: { x: number; y: number; z: number }, + ) => boolean; + } | null = null; + + private enabled = false; + private keys: Record = {}; + private mouseLookDragging = false; + + private yaw = 0; + private pitch = 0; + private position = new Vector3(); + private velocity = new Vector3(); + private grounded = false; + private groundYFiltered: number | null = null; + private horizontalSpeed = 0; + + private cameraPosition = new Vector3(); + private cameraRotation = new Euler(0, 0, 0, 'YXZ'); + private cameraScale = new Vector3(1, 1, 1); + private characterPosition = new Vector3(); + private cameraTarget = new Vector3(); + private cameraIdealPosition = new Vector3(); + private cameraCollisionPosition = new Vector3(); + private cameraRay = new Vector3(); + + private accumulator = 0; + moveSpeed = 7; + + thirdPersonEnabled = false; + private thirdPersonDistance = 3.2; + private thirdPersonDistanceTarget = 3.2; + private thirdPersonDistanceMin = 0.8; + private thirdPersonDistanceMax = 4; + private thirdPersonBounceOffset = 0; + private thirdPersonBounceVelocity = 0; + private thirdPersonTargetHeight = 1.25; + private characterYaw = 0; + private thirdPersonCollisionDistance = -1; + private thirdPersonOcclusionReleaseTimer = 0; + thirdPersonCameraPreset: 'indoor' | 'outdoor' = 'indoor'; + + constructor(private container: HTMLElement) { + document.addEventListener('keydown', this.onKeyDown); + document.addEventListener('keyup', this.onKeyUp); + document.addEventListener('mousedown', this.onMouseDown); + document.addEventListener('mouseup', this.onMouseUp); + document.addEventListener('mousemove', this.onMouseMove); + document.addEventListener('wheel', this.onWheel, { passive: false }); + // Prevent "stuck key" drift when keyup is lost (UI panel focus, pointer-lock, tab blur). + document.addEventListener('pointerdown', this.onDocumentPointerDown, true); + document.addEventListener('focusin', this.onDocumentFocusIn, true); + window.addEventListener('blur', this.onWindowBlur); + document.addEventListener('visibilitychange', this.onVisibilityChange); + } + + /** Attach voxel collision data used by ground checks and capsule push-out. */ + loadVoxelCollision( + metadata: { + gridBounds: { min: number[]; max: number[] }; + voxelResolution: number; + leafSize: number; + treeDepth: number; + }, + nodes: Uint32Array, + leafData: Uint32Array, + ) { + this.collision = new VoxelCollision(metadata, nodes, leafData); + } + + /** Place the walker at a known position and camera angle. */ + startAtPose(position: InstanceType, yaw: number, pitch: number) { + this.position.copy(position); + this.velocity.set(0, 0, 0); + this.yaw = yaw; + this.pitch = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, pitch)); + this.activateAtCurrentPose(); + } + + /** Reset runtime state, resolve spawn collision, and snap to the ground if one is below. */ + private activateAtCurrentPose() { + this.enabled = true; + this.keys = {}; + this.accumulator = 0; + this.grounded = false; + this.horizontalSpeed = 0; + this.characterYaw = this.yaw; + this.thirdPersonDistanceTarget = this.thirdPersonDistance; + this.thirdPersonCollisionDistance = -1; + this.thirdPersonOcclusionReleaseTimer = 0; + this.groundYFiltered = null; + this.resolveSpawnCollision(); + const gy = this.probeGround(this.position); + if (gy !== null) { + this.grounded = true; + this.velocity.y = 0; + this.position.y = gy + WALK_HOVER_HEIGHT + WALK_EYE_HEIGHT; + this.groundYFiltered = gy; + } + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + } + + /** Stop walk mode and clear held input. */ + disable() { + this.enabled = false; + this.clearInputState(); + } + + /** Set third-person orbit distance and look height. */ + setThirdPersonCamera(distance: number, targetHeight: number, minDistance = 0.8, maxDistance = 4) { + this.thirdPersonDistanceMin = Math.max(0.2, Math.min(4, minDistance)); + const maxCap = 1_000_000; + this.thirdPersonDistanceMax = Math.max(this.thirdPersonDistanceMin + 0.1, Math.min(maxCap, maxDistance)); + const clampedDistance = Math.max(this.thirdPersonDistanceMin, Math.min(this.thirdPersonDistanceMax, distance)); + this.thirdPersonDistance = clampedDistance; + this.thirdPersonDistanceTarget = clampedDistance; + this.thirdPersonTargetHeight = Math.max(0.4, Math.min(maxCap, targetHeight)); + } + /** Called once per frame; runs fixed physics steps and then updates the camera state. */ + update(dt: number) { + if (!this.enabled) { + return; + } + const dtClamped = Math.min(Math.max(0, dt), 1 / 20); + this.accumulator = Math.min(this.accumulator + dtClamped, MAX_SUBSTEPS * WALK_SIMULATION_STEP_SECONDS); + while (this.accumulator >= WALK_SIMULATION_STEP_SECONDS) { + this.step(WALK_SIMULATION_STEP_SECONDS); + this.accumulator -= WALK_SIMULATION_STEP_SECONDS; + } + this.updateCharacterPosition(); + if (this.thirdPersonEnabled) { + this.updateThirdPersonCamera(dtClamped); + } else { + this.cameraPosition.set(this.position.x, this.position.y, this.position.z); + this.cameraRotation.set(this.pitch, this.yaw, 0, 'YXZ'); + } + } + + /** Current camera transform for the render scene. */ + getCameraState() { + return { position: this.cameraPosition, rotation: this.cameraRotation, scale: this.cameraScale }; + } + + /** Current avatar state for the third-person model. */ + getCharacterState(): ViewerWalkCharacterState { + return { + position: this.characterPosition, + yaw: this.characterYaw, + speed: this.horizontalSpeed, + walkSpeed: this.moveSpeed, + verticalVelocity: this.velocity.y, + grounded: this.grounded, + }; + } + + /** One fixed physics step: ground probe, gravity, horizontal movement, and voxel push-out. */ + private step(dt: number) { + const rawGroundY = this.probeGround(this.position); + const hasGround = rawGroundY !== null; + + if (hasGround && rawGroundY !== null) { + if (this.groundYFiltered === null) { + this.groundYFiltered = rawGroundY; + } else { + const a = 1 - Math.exp(-20 * dt); + this.groundYFiltered += (rawGroundY - this.groundYFiltered) * a; + } + } else if (!hasGround) { + this.groundYFiltered = null; + } + + const groundYStick = hasGround && this.groundYFiltered !== null ? this.groundYFiltered : rawGroundY; + + if (hasGround) { + const groundYValue = groundYStick as number; + const targetY = groundYValue + WALK_HOVER_HEIGHT + WALK_EYE_HEIGHT; + const displacement = this.position.y - targetY; + if (displacement > 0.1) { + this.velocity.y -= WALK_GRAVITY * dt; + const nextY = this.position.y + this.velocity.y * dt; + if (nextY <= targetY) { + this.position.y = targetY; + this.velocity.y = 0; + } + this.grounded = false; + } else { + const spring = -800 * displacement - 57 * this.velocity.y; + this.velocity.y += spring * dt; + this.grounded = true; + } + } else { + this.velocity.y -= WALK_GRAVITY * dt; + this.grounded = false; + } + + const forwardInput = (this.keys.KeyW ? 1 : 0) - (this.keys.KeyS ? 1 : 0); + const strafeInput = (this.keys.KeyD ? 1 : 0) - (this.keys.KeyA ? 1 : 0); + const move = new Vector3(); + const hasMoveInput = forwardInput !== 0 || strafeInput !== 0; + const forward = new Vector3(-Math.sin(this.yaw), 0, -Math.cos(this.yaw)); + const right = new Vector3().crossVectors(forward, new Vector3(0, 1, 0)); + if (forwardInput !== 0) { + move.addScaledVector(forward, forwardInput); + } + if (strafeInput !== 0) { + move.addScaledVector(right, strafeInput); + } + if (hasMoveInput) { + const maxSpeed = this.moveSpeed; + move.normalize().multiplyScalar(maxSpeed); + this.characterYaw = Math.atan2(-move.x, -move.z); + } else { + move.set(0, 0, 0); + } + const accel = this.grounded ? 24 : 6; + const blend = Math.min(1, accel * dt); + this.velocity.x = this.velocity.x + (move.x - this.velocity.x) * blend; + this.velocity.z = this.velocity.z + (move.z - this.velocity.z) * blend; + const dampFactor = this.grounded ? 0.99 : 0.998; + const alpha = this.damp(dampFactor, dt); + this.velocity.x = this.lerp(this.velocity.x, 0, alpha * 0.35); + this.velocity.z = this.lerp(this.velocity.z, 0, alpha * 0.35); + this.horizontalSpeed = Math.hypot(this.velocity.x, this.velocity.z); + + this.position.addScaledVector(this.velocity, dt); + this.resolveCollision(); + } + + /** Build a third-person camera from avatar position, pitch, zoom, and collision. */ + private updateThirdPersonCamera(dt: number) { + this.updateThirdPersonDistance(dt); + const cameraScale = THIRD_PERSON_MODEL_SCALE; + const meshWorldH = CHARACTER_HEIGHT_METERS * cameraScale; + const { pivotY, baseElevation } = this.computeThirdPersonCameraTarget(meshWorldH, cameraScale); + + this.cameraTarget.set(this.position.x, pivotY, this.position.z); + + const elevation = Math.max((-80 * Math.PI) / 180, Math.min((70 * Math.PI) / 180, baseElevation + this.pitch)); + const activeDistance = Math.max(0.1, (this.thirdPersonDistance + this.thirdPersonBounceOffset) * cameraScale); + const horizontalDistance = Math.cos(elevation) * activeDistance; + const verticalOffset = Math.sin(elevation) * activeDistance; + this.cameraIdealPosition.set( + this.cameraTarget.x + Math.sin(this.yaw) * horizontalDistance, + this.cameraTarget.y + verticalOffset, + this.cameraTarget.z + Math.cos(this.yaw) * horizontalDistance, + ); + this.resolveCameraCollision(dt, activeDistance); + this.cameraCollisionPosition.y = Math.max(this.cameraCollisionPosition.y, this.characterPosition.y + 0.12); + + this.cameraPosition.copy(this.cameraCollisionPosition); + this.cameraRotation.set(-elevation, this.yaw, 0, 'YXZ'); + } + + /** Pick the vertical target point for indoor and outdoor third-person views. */ + private computeThirdPersonCameraTarget(meshWorldHeight: number, cameraScale: number) { + if (this.thirdPersonCameraPreset === 'outdoor') { + return { + pivotY: this.characterPosition.y + meshWorldHeight * 0.92, + baseElevation: 0.4, + }; + } + + const pivotY = + this.position.y - + WALK_EYE_HEIGHT + + WALK_HOVER_HEIGHT + + this.thirdPersonTargetHeight * cameraScale - + 0.22 * meshWorldHeight; + + return { + pivotY: Math.max(this.characterPosition.y + meshWorldHeight * 0.06, pivotY), + baseElevation: 0.35, + }; + } + + /** Smooth zoom changes and the small zoom bounce. */ + private updateThirdPersonDistance(dt: number) { + const alpha = Math.min(1, Math.max(0, 12 * dt)); + this.thirdPersonDistance = this.lerp(this.thirdPersonDistance, this.thirdPersonDistanceTarget, alpha); + const spring = -this.thirdPersonBounceOffset * 70; + const damping = -this.thirdPersonBounceVelocity * 12; + this.thirdPersonBounceVelocity += (spring + damping) * dt; + this.thirdPersonBounceVelocity = Math.max(-6, Math.min(6, this.thirdPersonBounceVelocity)); + this.thirdPersonBounceOffset += this.thirdPersonBounceVelocity * dt; + this.thirdPersonBounceOffset = Math.max(-0.5, Math.min(0.5, this.thirdPersonBounceOffset)); + if (Math.abs(this.thirdPersonBounceOffset) < 5e-4 && Math.abs(this.thirdPersonBounceVelocity) < 0.005) { + this.thirdPersonBounceOffset = 0; + this.thirdPersonBounceVelocity = 0; + } + } + + /** Pull the third-person camera forward when voxels block the view. */ + private resolveCameraCollision(dt: number, maxDistance: number) { + this.cameraRay.subVectors(this.cameraIdealPosition, this.cameraTarget); + const distance = this.cameraRay.length(); + if (distance < 1e-4) { + this.cameraCollisionPosition.copy(this.cameraIdealPosition); + this.thirdPersonCollisionDistance = distance; + return; + } + this.cameraRay.multiplyScalar(1 / distance); + let blockedDistance = maxDistance; + let blocked = false; + if (this.collision) { + const hit = this.collision.queryRay( + this.cameraTarget.x, + this.cameraTarget.y, + this.cameraTarget.z, + this.cameraRay.x, + this.cameraRay.y, + this.cameraRay.z, + distance, + ); + if (hit) { + blockedDistance = Math.max(0.1, this.cameraTarget.distanceTo(new Vector3(hit.x, hit.y, hit.z)) - 0.18); + blocked = true; + this.thirdPersonOcclusionReleaseTimer = 0.1; + } + } + if (!blocked && this.thirdPersonOcclusionReleaseTimer > 0) { + this.thirdPersonOcclusionReleaseTimer = Math.max(0, this.thirdPersonOcclusionReleaseTimer - dt); + blocked = this.thirdPersonOcclusionReleaseTimer > 0; + } + const desiredDistance = blocked ? blockedDistance : maxDistance; + if (this.thirdPersonCollisionDistance < 0) { + this.thirdPersonCollisionDistance = desiredDistance; + } else { + const rate = desiredDistance < this.thirdPersonCollisionDistance ? 14 : 7; + const alpha = 1 - Math.exp(-Math.max(0, dt) * rate); + this.thirdPersonCollisionDistance = this.lerp(this.thirdPersonCollisionDistance, desiredDistance, alpha); + } + this.thirdPersonCollisionDistance = Math.max(0.1, Math.min(maxDistance, this.thirdPersonCollisionDistance)); + this.cameraCollisionPosition + .copy(this.cameraTarget) + .addScaledVector(this.cameraRay, this.thirdPersonCollisionDistance); + } + + /** Place the avatar feet on the current ground height. */ + private updateCharacterPosition() { + const groundY = + this.grounded && this.groundYFiltered !== null + ? this.groundYFiltered + : this.grounded + ? this.probeGround(this.position) + : null; + const footY = groundY !== null ? groundY : this.position.y - WALK_HOVER_HEIGHT - WALK_EYE_HEIGHT; + this.characterPosition.set(this.position.x, footY, this.position.z); + } + + /** Raycast below the capsule and return a stable ground height. */ + private probeGround(pos: InstanceType): number | null { + if (!this.collision) { + return null; + } + const oy = pos.y - WALK_EYE_HEIGHT; + const r = WALK_CAPSULE_RADIUS; + const samples: Array<[number, number]> = [ + [0, 0], + [-r, 0], + [r, 0], + [0, r], + [0, -r], + ]; + const ys: number[] = []; + for (let i = 0; i < samples.length; i++) { + const [ox, oz] = samples[i]; + const hit = this.collision.queryRay(pos.x + ox, oy, pos.z + oz, 0, -1, 0, 1.0); + if (!hit) { + continue; + } + ys.push(hit.y); + } + if (ys.length === 0) { + return null; + } + ys.sort((a, b) => a - b); + const mid = Math.floor(ys.length / 2); + return ys.length % 2 === 1 ? ys[mid]! : (ys[mid - 1]! + ys[mid]!) * 0.5; + } + + /** Push the moving capsule out of solid voxels. */ + private resolveCollision() { + if (!this.collision) { + return; + } + const centerY = this.position.y - WALK_EYE_HEIGHT + WALK_CAPSULE_HEIGHT * 0.5; + const half = WALK_CAPSULE_HEIGHT * 0.5 - WALK_CAPSULE_RADIUS; + const push = { x: 0, y: 0, z: 0 }; + if (this.collision.queryCapsule(this.position.x, centerY, this.position.z, half, WALK_CAPSULE_RADIUS, push)) { + this.position.x += push.x; + this.position.y += push.y; + this.position.z += push.z; + if (push.y < -PENETRATION_EPSILON && this.velocity.y > 0) { + this.velocity.y = 0; + } + if (!this.grounded && push.y > PENETRATION_EPSILON && this.velocity.y < 0) { + this.velocity.y = 0; + this.grounded = true; + } + } + } + + /** Lift the start pose until the capsule is outside solid voxels. */ + private resolveSpawnCollision() { + if (!this.collision) { + return; + } + const half = WALK_CAPSULE_HEIGHT * 0.5 - WALK_CAPSULE_RADIUS; + const minStep = WALK_CAPSULE_RADIUS; + const push = { x: 0, y: 0, z: 0 }; + for (let i = 0; i < 100; i++) { + const center = this.position.y - WALK_EYE_HEIGHT + WALK_CAPSULE_HEIGHT * 0.5; + if ( + !this.collision.queryCapsule(this.position.x, center, this.position.z, half, WALK_CAPSULE_RADIUS, push) + ) { + break; + } + this.position.y += Math.max(push.y, minStep); + } + } + + private damp(damping: number, dt: number) { + return 1 - Math.pow(damping, dt * 1000); + } + + private lerp(a: number, b: number, t: number) { + return a + (b - a) * t; + } + + private onKeyDown = (e: KeyboardEvent) => { + if (!this.enabled) { + return; + } + this.keys[e.code] = true; + if (e.code === 'KeyW' || e.code === 'KeyA' || e.code === 'KeyS' || e.code === 'KeyD') { + e.preventDefault(); + } + }; + + private onKeyUp = (e: KeyboardEvent) => { + if (!this.enabled) { + return; + } + this.keys[e.code] = false; + }; + + // Clear held keys when the user leaves the walk area or the page loses focus. + private onDocumentPointerDown = (e: PointerEvent) => { + this.clearInputWhenTargetLeavesContainer(e.target); + }; + + private onDocumentFocusIn = (e: FocusEvent) => { + this.clearInputWhenTargetLeavesContainer(e.target); + }; + + private clearInputWhenTargetLeavesContainer(target: EventTarget | null) { + if (!this.enabled) { + return; + } + if (target instanceof Node && !this.container.contains(target)) { + this.clearInputState(); + } + } + + private onWindowBlur = () => { + this.clearInputState(); + }; + + private onVisibilityChange = () => { + if (document.hidden) { + this.clearInputState(); + } + }; + + private clearInputState() { + this.keys = {}; + this.mouseLookDragging = false; + } + + private onMouseDown = (e: MouseEvent) => { + if (!this.enabled) { + return; + } + if (e.target instanceof Node && !this.container.contains(e.target)) { + return; + } + if (e.button === 0) { + this.mouseLookDragging = true; + e.preventDefault(); + } + }; + + private onMouseUp = (e: MouseEvent) => { + if (e.button === 0) { + this.mouseLookDragging = false; + } + }; + + private onMouseMove = (e: MouseEvent) => { + if (!this.enabled) { + return; + } + if (!this.mouseLookDragging || (e.buttons & 1) === 0) { + this.mouseLookDragging = false; + return; + } + const sensitivity = 0.002; + this.yaw -= e.movementX * sensitivity; + this.pitch += (this.thirdPersonEnabled ? 1 : -1) * e.movementY * sensitivity; + this.pitch = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, this.pitch)); + }; + + private onWheel = (e: WheelEvent) => { + if (!this.enabled || !this.thirdPersonEnabled) { + return; + } + e.preventDefault(); + let next = this.thirdPersonDistanceTarget + e.deltaY * 0.002; + if (next < this.thirdPersonDistanceMin) { + this.thirdPersonBounceVelocity += (next - this.thirdPersonDistanceMin) * 0.9; + next = this.thirdPersonDistanceMin; + } else if (next > this.thirdPersonDistanceMax) { + this.thirdPersonBounceVelocity += (next - this.thirdPersonDistanceMax) * 0.9; + next = this.thirdPersonDistanceMax; + } + this.thirdPersonBounceVelocity = Math.max(-6, Math.min(6, this.thirdPersonBounceVelocity)); + this.thirdPersonDistanceTarget = next; + }; +} + +// ----------------------------------------------------------------------------- +// Demo assets and character presentation +// ----------------------------------------------------------------------------- + +/** Aholo OSS walk assets (`oss-res` -> `node uploader/index.mjs gs:aholo`); indoor `gs_file/room/`, outdoor `gs_file/juguo/`. */ +const AHOLO_OSS_GS_FILE_BASE = 'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file'; +const WALK_INDOOR_URL_PREFIX = `${AHOLO_OSS_GS_FILE_BASE}/room/`; +const WALK_OUTDOOR_URL_PREFIX = `${AHOLO_OSS_GS_FILE_BASE}/juguo/`; + +/** Third-person GLB assets; tune with `scripts/tune-character-glb-to-walk.mjs`. */ +const WALK_CHARACTER_MODEL_URL_MAN = `${AHOLO_OSS_GS_FILE_BASE}/misc/man-final.755ce8ea.glb`; +const WALK_CHARACTER_MODEL_URL_ROBOT = `${AHOLO_OSS_GS_FILE_BASE}/misc/robot.0765006a.glb`; + +/** Normalized third-person GLB height. */ +const CHARACTER_HEIGHT_METERS = 1.75; + +const FADE_SECONDS = 0.18; +/** Idle/walk speed thresholds. */ +const CHARACTER_LOCOMOTION_IDLE_ENTER_SPEED = 0.05; +const CHARACTER_LOCOMOTION_WALK_ENTER_SPEED = 0.12; +const CHARACTER_STAIR_FALL_VERTICAL_SPEED = -0.85; +type CharacterActionName = 'Idle' | 'Walk' | 'Fall'; + +interface ActionFade { + action: InstanceType; + from: number; + to: number; + elapsed: number; + duration: number; + deactivateOnComplete: boolean; +} + +/** Third-person avatar rendered in the same scene as the splats. */ +export class WalkThirdPersonCharacter { + private readonly scene: Scene3D; + private readonly viewer: Viewer; + private readonly animationPlugin: InstanceType; + + private readonly characterRoot = new Object3D(); + private readonly lights = new Object3D(); + private mixer: InstanceType | null = null; + private actions: Partial>> = {}; + private activeAction: InstanceType | null = null; + private activeActionName: CharacterActionName | null = null; + private locomotionAnim: 'Idle' | 'Walk' = 'Idle'; + private actionFades: ActionFade[] = []; + + private enabled = false; + private loaded = false; + private loadError = false; + private loadPromise: Promise | undefined; + /** Guards async character loading after disposal. */ + private readonly lifetime = new AbortController(); + + private smoothedYaw = 0; + + private tmpPos = new Vector3(); + + constructor( + scene: Scene3D, + viewer: Viewer, + private readonly modelUrl: string, + ) { + this.scene = scene; + this.viewer = viewer; + this.animationPlugin = new AnimationPlugin(); + this.animationPlugin.registerToViewer({ viewer } as any); + + this.characterRoot.visible = false; + this.characterRoot.scale.setScalar(THIRD_PERSON_MODEL_SCALE); + this.lights.visible = false; + + const ambient = new AmbientLight(0xffffff, 0.72); + const key = new DirectionalLight(0xffffff, 1.15); + key.position.set(0.4, 1.0, 0.35); + const fill = new DirectionalLight(0xffffff, 0.35); + fill.position.set(-0.7, 0.6, -0.4); + this.lights.add(ambient); + this.lights.add(key); + this.lights.add(fill); + } + + /** Show the avatar and start loading it when needed. */ + setEnabled(enabled: boolean) { + this.enabled = enabled; + this.characterRoot.visible = enabled && this.loaded && !this.loadError; + this.lights.visible = enabled && this.loaded && !this.loadError; + if (enabled) { + this.ensureLoaded(); + } + } + + /** Add avatar objects to the active scene. */ + private attachToScene(): void { + if (this.lights.parent !== this.scene) { + this.scene.add(this.lights); + } + if (this.characterRoot.parent !== this.scene) { + this.scene.add(this.characterRoot); + } + this.scene.notifySceneChange(); + } + + /** Match the GLB position, yaw, and animation to walk state. */ + update(state: ViewerWalkCharacterState, _dt: number) { + if (!this.enabled || !this.loaded || this.loadError) { + return; + } + const p = state.position; + this.tmpPos.set(p.x, p.y, p.z); + this.characterRoot.position.copy(this.tmpPos); + this.smoothCharacterYaw(state.yaw, _dt); + this.characterRoot.rotation.y = this.smoothedYaw + Math.PI; + this.characterRoot.updateMatrixWorld(true); + + if (this.mixer) { + this.playAction(this.resolveActionName(state), state); + this.updateActionFades(_dt); + } + } + + /** Start the GLB load once. */ + private ensureLoaded() { + if (this.loaded || this.loadError || this.loadPromise) { + return; + } + this.loadPromise = this.loadCharacter(); + } + + /** Resolves when the GLB is ready; rejects on load failure or `signal` abort. */ + waitUntilReady(signal: AbortSignal): Promise { + if (this.loaded) { + return Promise.resolve(); + } + if (this.loadError) { + return Promise.reject(new Error('[walk] Third-person character failed to load.')); + } + this.ensureLoaded(); + if (!this.loadPromise) { + return Promise.reject(new Error('[walk] Third-person character load did not start.')); + } + throwIfAborted(signal); + return new Promise((resolve, reject) => { + const onAbort = () => { + reject(new DOMException('Aborted', 'AbortError')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + this.loadPromise!.then( + () => { + signal.removeEventListener('abort', onAbort); + if (this.lifetime.signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + if (this.loadError || !this.loaded) { + reject(new Error('[walk] Third-person character failed to load.')); + return; + } + resolve(); + }, + error => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + }); + } + + /** Load the GLB, bind animation, fit its size, and attach it to the scene. */ + private async loadCharacter() { + const { signal } = this.lifetime; + try { + const response = await fetch(this.modelUrl, { signal }); + throwIfAborted(signal); + const buffer = await response.arrayBuffer(); + throwIfAborted(signal); + const result = await loadGLTF(buffer, { + textureLoader: downloadTexture, + }); + throwIfAborted(signal); + + // GLTF loader typings differ; scene graph objects are runtime-compatible with our Scene3D. + const model = result.scene as any; + this.characterRoot.removeAllChildren(); + this.mixer = new AnimationMixer(model); + this.animationPlugin.add(this.mixer); + + const boundSkinnedMeshes = new WeakSet(); + result.skeletons.forEach((skinnedMeshes: any, iSkeleton: any) => { + const skeleton = new Skeleton(iSkeleton.bones as any, iSkeleton.inverseBindMatrices as any); + skinnedMeshes.forEach((skinnedMesh: any) => { + if (boundSkinnedMeshes.has(skinnedMesh)) { + return; + } + boundSkinnedMeshes.add(skinnedMesh); + this.animationPlugin.bindSkinned(skinnedMesh as any, skeleton, this.mixer as any); + }); + }); + // After `bindSkinned` -> `SkinnedMesh.bind`, `worldBoundingBox` uses bone matrices (not raw POSITION AABB). + this.normalizeModel(model); + this.characterRoot.add(model); + this.setupActions((result.animations || []) as AnimationClip[]); + + throwIfAborted(signal); + this.attachToScene(); + + this.loaded = true; + this.characterRoot.visible = this.enabled; + this.lights.visible = this.enabled; + } catch (e) { + if (signal.aborted) { + return; + } + console.error('[walk] Third-person character load failed:', e); + this.loadError = true; + } + } + + /** Meshes used to fit character height and foot origin. */ + private pickBodyDrawableNodes(model: Object3D): Object3D[] { + const surface: Object3D[] = []; + const nonJoint: Object3D[] = []; + model.traverse(node => { + const o = node as { isMesh?: boolean; isSkinnedMesh?: boolean }; + if (!o.isMesh && !o.isSkinnedMesh) { + return; + } + const name = (node.name || '').toLowerCase(); + if (name.includes('surface')) { + surface.push(node); + } else if (!name.includes('joint')) { + nonJoint.push(node); + } + }); + return surface.length > 0 ? surface : nonJoint.length > 0 ? nonJoint : []; + } + + /** Bounds used to fit the character model. */ + private unionCharacterNormalizeBounds(model: Object3D): InstanceType { + const targets = this.pickBodyDrawableNodes(model); + model.updateMatrixWorld(true); + const box = new Box3(); + if (targets.length > 0) { + let first = true; + for (const n of targets) { + const sm = n as { + isSkinnedMesh?: boolean; + update?: () => void; + worldBoundingBox?: InstanceType; + }; + if (sm.isSkinnedMesh && typeof sm.update === 'function' && sm.worldBoundingBox) { + sm.update(); + const wb = sm.worldBoundingBox; + if (!wb.isEmpty()) { + const dy = wb.max.y - wb.min.y; + if (Number.isFinite(dy) && dy > 1e-9) { + if (first) { + box.copy(wb); + first = false; + } else { + box.union(wb); + } + continue; + } + } + } + const b = new Box3().setFromObject(n); + const dy = b.max.y - b.min.y; + if (!Number.isFinite(dy) || dy <= 1e-9) { + continue; + } + if (first) { + box.copy(b); + first = false; + } else { + box.union(b); + } + } + if (!first && box.max.y - box.min.y > 1e-6) { + return box; + } + } + return new Box3().setFromObject(model); + } + + /** Scale to CHARACTER_HEIGHT_METERS and move the feet to the origin. */ + private normalizeModel(model: Object3D) { + model.updateMatrixWorld(true); + const box = this.unionCharacterNormalizeBounds(model); + const sourceHeight = Math.max(1e-6, box.max.y - box.min.y); + const s = CHARACTER_HEIGHT_METERS / sourceHeight; + model.scale.setScalar(s); + model.updateMatrixWorld(true); + const scaled = this.unionCharacterNormalizeBounds(model); + const midX = (scaled.min.x + scaled.max.x) * 0.5; + const midZ = (scaled.min.z + scaled.max.z) * 0.5; + model.position.x -= midX; + model.position.y -= scaled.min.y; + model.position.z -= midZ; + } + + /** Pick idle, walk, and fall clips from the GLB animation list. */ + private setupActions(clips: AnimationClip[]) { + if (!this.mixer) { + return; + } + const findClip = (name: string) => { + const normalized = name.toLowerCase(); + return clips.find(c => c.name.toLowerCase().indexOf(normalized) >= 0) || null; + }; + this.actions = {}; + const map: Partial> = { + Idle: findClip('idle'), + Walk: findClip('walk') || findClip('run') || findClip('mixamo.com'), + Fall: findClip('fall') || findClip('jump'), + }; + + (Object.keys(map) as CharacterActionName[]).forEach(name => { + const clip = map[name]; + if (!clip) { + return; + } + const action = this.mixer!.clipAction(clip); + action.active = false; + action.weight = 0; + this.actions[name] = action; + }); + + const idle = this.actions.Idle || Object.values(this.actions)[0] || null; + this.activateAction(idle, 0, 'Idle'); + } + + /** Choose the avatar animation from ground and speed state. */ + private resolveActionName(state: ViewerWalkCharacterState): CharacterActionName { + if (!state.grounded) { + // Small steps can briefly set grounded=false; keep idle/walk unless actually falling. + if (state.speed >= CHARACTER_LOCOMOTION_WALK_ENTER_SPEED) { + return this.resolveLocomotionAction(state); + } + if (state.verticalVelocity > CHARACTER_STAIR_FALL_VERTICAL_SPEED) { + return this.resolveLocomotionAction(state); + } + return 'Fall'; + } + return this.resolveLocomotionAction(state); + } + + /** Switch between idle and walk with a small speed gap. */ + private resolveLocomotionAction(state: ViewerWalkCharacterState): 'Idle' | 'Walk' { + if (this.locomotionAnim === 'Walk') { + if (state.speed < CHARACTER_LOCOMOTION_IDLE_ENTER_SPEED) { + this.locomotionAnim = 'Idle'; + } + } else if (state.speed > CHARACTER_LOCOMOTION_WALK_ENTER_SPEED) { + this.locomotionAnim = 'Walk'; + } + return this.locomotionAnim; + } + + /** Update speed and fade to the requested animation. */ + private playAction(name: CharacterActionName, state: ViewerWalkCharacterState) { + const next = this.actions[name] || this.actions.Idle || null; + this.updateActionSpeed(name, next, state); + this.activateAction(next, FADE_SECONDS, name); + } + + /** Fade from the current animation to the next one. */ + private activateAction( + next: InstanceType | null, + fadeSeconds: number, + nextName: CharacterActionName, + ) { + if (next === this.activeAction) { + return; + } + const prevName = this.activeActionName; + const softLocomotionHandoff = + prevName !== null && + (prevName === 'Idle' || prevName === 'Walk') && + (nextName === 'Idle' || nextName === 'Walk'); + if (this.activeAction) { + this.fadeAction(this.activeAction, 0, fadeSeconds, true); + } + this.activeAction = next; + this.activeActionName = next !== null ? nextName : null; + if (this.activeAction) { + if (!softLocomotionHandoff || this.activeAction.weight < 0.02) { + this.activeAction.reset(); + } + this.activeAction.active = true; + this.fadeAction(this.activeAction, 1, fadeSeconds, false); + } + } + + /** Queue a weight fade for one animation action. */ + private fadeAction( + action: InstanceType, + targetWeight: number, + duration: number, + deactivateOnComplete: boolean, + ) { + this.actionFades = this.actionFades.filter(fade => fade.action !== action); + if (duration <= 0) { + action.weight = targetWeight; + action.active = targetWeight > 0 || !deactivateOnComplete; + return; + } + action.active = true; + this.actionFades.push({ + action, + from: action.weight, + to: targetWeight, + elapsed: 0, + duration, + deactivateOnComplete, + }); + } + + /** Advance queued animation fades. */ + private updateActionFades(dt: number) { + if (this.actionFades.length === 0) { + return; + } + const remaining: ActionFade[] = []; + for (const fade of this.actionFades) { + fade.elapsed += Math.max(0, dt); + const t = Math.min(1, fade.elapsed / fade.duration); + fade.action.weight = fade.from + (fade.to - fade.from) * t; + if (t < 1) { + remaining.push(fade); + } else if (fade.deactivateOnComplete) { + fade.action.active = false; + } + } + this.actionFades = remaining; + } + + /** Match walk animation speed to movement speed. */ + private updateActionSpeed( + name: CharacterActionName, + action: InstanceType | null, + state: ViewerWalkCharacterState, + ) { + if (!action) { + return; + } + if (name === 'Walk') { + action.speed = Math.max(0.35, Math.min(0.8, state.speed / Math.max(0.001, state.walkSpeed))); + } else { + action.speed = 1; + } + } + + /** Smooth avatar turning so direction changes are not sharp. */ + private smoothCharacterYaw(targetYaw: number, dt: number) { + const wrapped = Math.atan2(Math.sin(targetYaw - this.smoothedYaw), Math.cos(targetYaw - this.smoothedYaw)); + const alpha = 1 - Math.exp(-Math.max(0, dt) * 14); + this.smoothedYaw += wrapped * alpha; + } + + /** Remove avatar objects and stop animation resources. */ + dispose(): void { + this.lifetime.abort(); + this.loadPromise = undefined; + try { + (this.viewer as any).unregisterPlugin(this.animationPlugin as never); + } catch { + /* ignore */ + } + this.animationPlugin.destroy(); + this.characterRoot.removeFromParent(); + this.lights.removeFromParent(); + this.mixer = null; + this.actions = {}; + this.activeAction = null; + this.activeActionName = null; + this.locomotionAnim = 'Idle'; + this.actionFades = []; + this.loaded = false; + this.loadError = false; + } +} + +// ----------------------------------------------------------------------------- +// Demo scene resources and wiring +// ----------------------------------------------------------------------------- + +const WALK_CAMERA = { + fov: 60, + aspect: 1, + near: 0.1, + /** Large scans use world units much greater than 1e3; keep a generous perspective far plane. */ + far: 1_000_000, +} as const; + +const LOD_MAGIC_CODE = 2500660; +const WALK_SPLAT_PACK_TYPE = SplatPackType.Compressed; +const WALK_MAX_SH_DEGREE = 3; + +type WalkLodMeta = SplatUtils.LodMeta; +type LodSplatInstance = InstanceType; + +/** Reference third-person camera tuning. */ +const REF_THIRD_PERSON = { + modelScale: 0.3, + cameraDistance: 3.2, + targetHeight: 1.25, +} as const; + +const THIRD_PERSON_CAMERA = { + distance: (REF_THIRD_PERSON.cameraDistance * REF_THIRD_PERSON.modelScale) / THIRD_PERSON_MODEL_SCALE, + targetHeight: (REF_THIRD_PERSON.targetHeight * REF_THIRD_PERSON.modelScale) / THIRD_PERSON_MODEL_SCALE, +} as const; + +type WalkThirdPersonCharacterId = 'man' | 'robot'; + +const WALK_THIRD_PERSON_CHARACTER_URLS: Record = { + man: WALK_CHARACTER_MODEL_URL_MAN, + robot: WALK_CHARACTER_MODEL_URL_ROBOT, +}; + +/** Walk scene: splats or LOD stream on the shared preview viewer. */ +class WalkDemoScene { + private readonly viewer: Viewer; + private readonly scene: Scene3D; + private readonly splatLayer = new Object3D(); + private readonly camera: PerspectiveCamera; + private lodSplat: LodSplatInstance | null = null; + private thirdPerson: WalkThirdPersonCharacter | null = null; + private thirdPersonModelUrl = WALK_CHARACTER_MODEL_URL_MAN; + + constructor(viewer: Viewer) { + this.viewer = viewer; + this.scene = this.viewer.getScene() as Scene3D; + this.applyViewerConfig(); + this.viewer.config.coordinateSystem.enabled.set(false); + + this.camera = new PerspectiveCamera(WALK_CAMERA.fov, WALK_CAMERA.aspect, WALK_CAMERA.near, WALK_CAMERA.far); + this.camera.position.set(0, 0, 1); + this.camera.rotation.set(0, 0, 0); + this.camera.enableFrustumCulling = false; + this.camera.enableDetailCulling = false; + this.viewer.setCamera(this.camera); + this.scene.add(this.splatLayer); + + const cul = (this.viewer as any).defaultViewport.drivenCullingConfig; + cul.frustumCullingEnabled = false; + cul.occlusionCullingEnabled = false; + cul.detailCullingEnabled = false; + cul.layersCullingEnabled = false; + cul.triCullingEnabled = false; + } + + /** Apply viewer settings needed by this demo. */ + applyViewerConfig(): void { + setViewerConfig(this.viewer, { + pipeline: { + Background: { + background: { + active: BackgroundMode.BasicBackground, + basic: { color: new Color(0, 0, 0), alpha: 1 }, + }, + ground: { enabled: false }, + }, + Splatting: { + enabled: true, + }, + TAA: { enabled: false }, + }, + }); + } + + /** Replace current splats with static splat files. */ + async loadSplatUrls(urls: readonly string[], signal: AbortSignal): Promise { + if (urls.length === 0) { + return; + } + this.clearSplats(); + await this.addSplatUrls(urls, signal); + } + + /** Add static splats without clearing the active LOD stream. */ + async appendSplatUrls(urls: readonly string[], signal: AbortSignal): Promise { + if (urls.length === 0) { + return; + } + await this.addSplatUrls(urls, signal); + } + + /** Fetch, parse, and add static splat files. */ + private async addSplatUrls(urls: readonly string[], signal: AbortSignal): Promise { + for (const url of urls) { + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`[walk] Splat URL failed: ${url} -> HTTP ${response.status}`); + } + const buffer = await response.arrayBuffer(); + throwIfAborted(signal); + const u8 = new Uint8Array(buffer); + const type = detectSplatFileType(url, u8); + if (type === undefined) { + throw new Error(`[walk] Unknown splat file type: ${url}`); + } + + const splatData = await parseSplatData(type, u8, WALK_SPLAT_PACK_TYPE, { + maxShDegree: WALK_MAX_SH_DEGREE, + maxTextureSize: 8192, + }); + throwIfAborted(signal); + const splat = await createSplat(splatData); + throwIfAborted(signal); + this.splatLayer.add(splat); + } + } + + /** Load the outdoor LOD stream and wait for the first chunk pass. */ + async loadLodStream( + metaUrl: string, + signal: AbortSignal, + loadResource: (url: string) => ReturnType, + ): Promise { + this.clearSplats(); + const meta = await loadWalkLodMeta(metaUrl, signal); + + const lodSplat = new LodSplat( + meta, + { + ...WALK_OUTDOOR_LOD_CONFIG, + minLevel: Math.max(0, meta.levels - 1), + schedulerParallelCounts: 99999, + schedulerExistingTaskLimit: 99999, + schedulerMinDuration: 0, + }, + createViewerContext(this.viewer), + loadResource, + ); + this.scene.add(lodSplat.container); + lodSplat.tick(this.camera); + lodSplat.start(); + await lodSplat.onFinishSchedule(); + throwIfAborted(signal); + + lodSplat.setConfig(WALK_OUTDOOR_LOD_CONFIG); + lodSplat.tick(this.camera); + this.lodSplat = lodSplat; + } + + /** Update LOD selection for the current camera. */ + tickLod(): void { + this.lodSplat?.tick(this.camera); + } + + /** Remove static splats and any active LOD stream. */ + private clearSplats(): void { + while (this.splatLayer.children.length > 0) { + const child = this.splatLayer.children[0]!; + this.splatLayer.remove(child); + if ('freeGPU' in child && typeof child.freeGPU === 'function') { + child.freeGPU(); + } + if ('destroy' in child && typeof child.destroy === 'function') { + child.destroy(); + } + } + if (this.lodSplat) { + this.lodSplat.destroy(); + this.lodSplat = null; + } + } + + /** Change the avatar GLB URL and reset the loaded avatar. */ + setThirdPersonModelUrl(url: string) { + if (this.thirdPersonModelUrl === url) { + return; + } + this.thirdPersonModelUrl = url; + this.thirdPerson?.dispose(); + this.thirdPerson = null; + } + + /** Create the avatar object when the view first needs it. */ + private ensureThirdPerson(): WalkThirdPersonCharacter { + if (!this.thirdPerson) { + this.thirdPerson = new WalkThirdPersonCharacter(this.scene, this.viewer, this.thirdPersonModelUrl); + } + return this.thirdPerson; + } + + /** Show or hide the third-person avatar. */ + setThirdPersonEnabled(enabled: boolean): void { + if (!enabled && !this.thirdPerson) { + return; + } + this.ensureThirdPerson().setEnabled(enabled); + } + + /** Wait until the avatar GLB is ready. */ + async waitForThirdPersonCharacter(signal: AbortSignal): Promise { + this.ensureThirdPerson(); + await this.thirdPerson!.waitUntilReady(signal); + } + + /** Update avatar pose and animation. */ + updateThirdPersonCharacter(state: ViewerWalkCharacterState, dt: number): void { + this.thirdPerson?.update(state, dt); + } + + /** Copy walk camera state to the viewer camera. */ + updateCamera(state: ReturnType): void { + this.camera.scale.copy(state.scale); + this.camera.rotation.copy(state.rotation); + this.camera.position.copy(state.position); + this.camera.updateMatrixWorld(true); + // Splat sorting depends on the viewer seeing this manual camera move as a real frame update. + (this.viewer as any).forceNextFrameRender = true; + } + + /** Remove demo scene objects. */ + dispose(): void { + this.thirdPerson?.dispose(); + this.thirdPerson = null; + this.clearSplats(); + this.splatLayer.removeFromParent(); + } +} + +// ----------------------------------------------------------------------------- +// Demo presets and loading helpers +// ----------------------------------------------------------------------------- + +type WalkViewMode = 'first' | 'third'; +type WalkDemoSchemeId = 'indoor' | 'outdoor'; + +/** Initial capsule center and camera angles. */ +interface WalkDemoInitialPose { + px: number; + py: number; + pz: number; + yaw: number; + pitch: number; + thirdPersonDistance?: number; +} + +interface WalkDemoScheme { + id: WalkDemoSchemeId; + splatMode: 'files' | 'lod'; + splatCandidates?: readonly string[]; + staticSplatUrls?: readonly string[]; + lodMetaUrl?: string; + voxelJson?: string; + voxelBin?: string; + pose: WalkDemoInitialPose; +} + +const WALK_DEMO_INDOOR_POSE: WalkDemoInitialPose = { + px: -4.148223469209742, + py: 1.0000000000000002, + pz: 1.2315243027420304, + yaw: -1.7860000000000005, + pitch: 0.082, + thirdPersonDistance: 3.3999999999999995, +}; + +const WALK_DEMO_OUTDOOR_POSE: WalkDemoInitialPose = { + px: 20.398008, + py: -0.15, + pz: 62.773942, + yaw: -0.384, + pitch: -0.672, + thirdPersonDistance: 3.6, +}; + +const WALK_DEMO_SCHEMES: Record = { + indoor: { + id: 'indoor', + splatMode: 'files', + splatCandidates: [`${WALK_INDOOR_URL_PREFIX}scene.7c26e842.spz`], + voxelJson: `${WALK_INDOOR_URL_PREFIX}voxel/10c88df3/collision.voxel-meta.json`, + voxelBin: `${WALK_INDOOR_URL_PREFIX}voxel/10c88df3/collision.voxel.bin`, + pose: WALK_DEMO_INDOOR_POSE, + }, + outdoor: { + id: 'outdoor', + splatMode: 'lod', + lodMetaUrl: `${WALK_OUTDOOR_URL_PREFIX}chunk-lod/0f9e3ae1/lod-meta.json`, + staticSplatUrls: [`${WALK_OUTDOOR_URL_PREFIX}environment.d3e129aa.ply`], + voxelJson: `${WALK_OUTDOOR_URL_PREFIX}voxel/309eccc1/collision.voxel-meta.json`, + voxelBin: `${WALK_OUTDOOR_URL_PREFIX}voxel/309eccc1/collision.voxel.bin`, + pose: WALK_DEMO_OUTDOOR_POSE, + }, +}; + +/** Outdoor juguo LOD stream cap (6M splats). */ +const WALK_OUTDOOR_LOD_MAX_BUDGET = 6_000_000; + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } +} + +function getWalkDemoUiStrings() { + const lang = (typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || ''; + const zh = lang.toLowerCase().startsWith('zh'); + return { + paneTitle: zh ? '行走模式' : 'Walk mode', + schemeLabel: zh ? '场景' : 'Scene', + schemeIndoor: zh ? '室内' : 'Indoor', + schemeOutdoor: zh ? '室外' : 'Outdoor', + viewLabel: zh ? '视角' : 'Camera', + first: zh ? '第一人称' : 'First-person', + third: zh ? '第三人称' : 'Third-person', + characterLabel: zh ? '第三人称模型' : 'Third-person model', + characterMan: zh ? '男性' : 'Man', + characterRobot: zh ? '机器人' : 'Robot', + }; +} + +async function loadWalkLodMeta(metaUrl: string, signal: AbortSignal): Promise { + const response = await fetch(metaUrl, { signal }); + if (!response.ok) { + throw new Error(`[walk] LOD metadata failed (${response.status} ${response.statusText}).`); + } + const content: unknown = await response.json(); + if (!isWalkLodMeta(content)) { + throw new Error('[walk] LOD metadata is not a supported lod-splat manifest.'); + } + return content; +} + +const WALK_OUTDOOR_LOD_CONFIG = { + minLevel: 0, + maxBudget: WALK_OUTDOOR_LOD_MAX_BUDGET, + backgroundPenalty: 1, + outsidePenalty: 1, + behindPenalty: 1, + behindTolerance: -0.2, + behindDistanceTolerance: 2, + hysteresisTicks: 4, + schedulerParallelCounts: 4, + schedulerExistingTaskLimit: 64, + schedulerMinDuration: 160, + debuggerEnabled: false, + debuggerType: 0 as const, +}; + +function createWalkOutdoorLodResourceLoader( + indexedDB: RuntimeIndexedDBStorage, + metaBaseUrl: string, + signal: AbortSignal, +) { + return async (url: string) => { + throwIfAborted(signal); + const resourceUrl = new URL(url, metaBaseUrl).toString(); + const cached = await indexedDB.get(resourceUrl, { version: 0 }); + throwIfAborted(signal); + if (cached) { + const splatData = new CompressedSplatData(); + splatData.deserialize(cached); + return splatData; + } + + const fileType = detectSplatFileType(resourceUrl, new Uint8Array()); + if (fileType === undefined) { + throw new Error(`[walk] Unsupported LOD resource: ${resourceUrl}`); + } + const splatData = await parseSplatData(fileType, resourceUrl, SplatPackType.Compressed); + throwIfAborted(signal); + await indexedDB.set(resourceUrl, splatData.serialize(), { version: 0 }); + return splatData; + }; +} + +function isReloadAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError'; +} + +function isWalkLodMeta(value: unknown): value is WalkLodMeta { + if (!value || typeof value !== 'object') { + return false; + } + const meta = value as Partial; + return meta.magicCode === LOD_MAGIC_CODE && meta.type === 'lod-splat'; +} + +// ----------------------------------------------------------------------------- +// Render runtime entry and demo shell +// ----------------------------------------------------------------------------- + +export default async function runner(ctx: RenderRuntime): Promise<() => void> { + const app = new WalkDemoApp(ctx); + await app.run(); + return () => { + app.dispose(); + }; +} + +/** Wires splats/LOD, collision, and walk mode on the render runtime. */ +class WalkDemoApp { + private readonly ctx: RenderRuntime; + private params: { + scheme: WalkDemoSchemeId; + viewMode: WalkViewMode; + thirdPersonCharacter: WalkThirdPersonCharacterId; + }; + private scene: WalkDemoScene | undefined; + private walk: ViewerWalkMode | undefined; + private running = false; + private reloadGeneration = 0; + private reloadAbort: AbortController | undefined; + private reloadChain: Promise = Promise.resolve(); + private hideLoadingOnFrame = false; + private restoredCamera: ReturnType | undefined; + private thirdPersonCharacterBinding: { refresh(): void } | undefined; + + constructor(ctx: RenderRuntime) { + this.ctx = ctx; + this.params = { + scheme: 'indoor', + viewMode: 'third', + thirdPersonCharacter: 'man', + }; + } + + /** Mount UI, start the frame loop, and load the first scene. */ + async run(): Promise { + this.mountConfigPanel(); + /** Frame callback returns whether the runtime should render. */ + this.ctx.renderer.frame(({ delta }) => this.onFrame(delta)); + await this.queueReloadScene(); + } + + /** Build the small runtime control panel. */ + private mountConfigPanel(): void { + if (!this.ctx.configPanel.available) { + return; + } + const ui = getWalkDemoUiStrings(); + const pane = this.ctx.configPanel.createPane({ title: ui.paneTitle }); + pane.addBinding(this.params, 'scheme', { + label: ui.schemeLabel, + options: { [ui.schemeIndoor]: 'indoor', [ui.schemeOutdoor]: 'outdoor' }, + }).on('change', () => { + this.params.thirdPersonCharacter = this.params.scheme === 'outdoor' ? 'robot' : 'man'; + this.thirdPersonCharacterBinding?.refresh(); + void this.queueReloadScene(); + }); + this.thirdPersonCharacterBinding = pane + .addBinding(this.params, 'thirdPersonCharacter', { + label: ui.characterLabel, + options: { [ui.characterMan]: 'man', [ui.characterRobot]: 'robot' }, + }) + .on('change', () => { + void this.swapThirdPersonCharacter(); + }); + pane.addBinding(this.params, 'viewMode', { + label: ui.viewLabel, + options: { [ui.first]: 'first', [ui.third]: 'third' }, + }).on('change', () => { + const walk = this.walk; + const scene = this.scene; + if (!walk || !scene) { + return; + } + this.applyViewMode(walk, scene); + if (this.params.viewMode === 'third') { + void this.swapThirdPersonCharacter(); + } + }); + } + + /** Apply third-person camera distance for the current scene. */ + private applyThirdPersonCameraToWalk(walk: ViewerWalkMode, scheme: WalkDemoScheme): void { + let tpDistance = THIRD_PERSON_CAMERA.distance; + if (this.params.viewMode === 'third' && scheme.pose.thirdPersonDistance != null) { + tpDistance = scheme.pose.thirdPersonDistance; + } + walk.setThirdPersonCamera(tpDistance, THIRD_PERSON_CAMERA.targetHeight, 0.8, Math.max(4, tpDistance)); + } + + /** Swap the avatar GLB without reloading splats or collision. */ + private async swapThirdPersonCharacter(): Promise { + const scene = this.scene; + if (!scene) { + return; + } + + const scheme = WALK_DEMO_SCHEMES[this.params.scheme]; + scene.setThirdPersonModelUrl(WALK_THIRD_PERSON_CHARACTER_URLS[this.params.thirdPersonCharacter]); + const walk = this.walk; + if (walk) { + this.applyThirdPersonCameraToWalk(walk, scheme); + } + + if (this.params.viewMode !== 'third') { + this.ctx.renderer.render(); + return; + } + + this.ctx.loading.show(); + try { + const signal = this.reloadAbort?.signal ?? this.ctx.signal; + await scene.waitForThirdPersonCharacter(signal); + if (walk) { + this.applyViewMode(walk, scene); + } + this.ctx.renderer.render(); + } catch (error) { + if (!isReloadAbortError(error) && !(error instanceof DOMException && error.name === 'AbortError')) { + console.error('[walk] Third-person character load failed:', error); + } + } finally { + if (this.running) { + this.ctx.loading.hide(); + } + } + } + + /** Apply first-person or third-person mode to walk and scene state. */ + private applyViewMode(walk: ViewerWalkMode, scene: WalkDemoScene): void { + const third = this.params.viewMode === 'third'; + walk.thirdPersonEnabled = third; + walk.thirdPersonCameraPreset = this.params.scheme === 'outdoor' ? 'outdoor' : 'indoor'; + const indoorSpeed = third ? 1.35 : 2.7; + walk.moveSpeed = this.params.scheme === 'outdoor' ? 2.15 : indoorSpeed; + scene.setThirdPersonEnabled(third); + } + + /** Serialize scene reloads so rapid UI changes do not overlap. */ + private queueReloadScene(): Promise { + this.reloadChain = this.reloadChain + .then(() => this.reloadScene()) + .catch(error => { + if (!isReloadAbortError(error)) { + console.error('[walk] Scene reload failed:', error); + } + }); + return this.reloadChain; + } + + /** Update walk, avatar, camera, and LOD once per frame. */ + private onFrame(delta: number): boolean { + if (!this.running) { + return false; + } + const sceneLoop = this.scene; + const walkLoop = this.walk; + if (!sceneLoop || !walkLoop) { + return false; + } + const scheme = WALK_DEMO_SCHEMES[this.params.scheme]; + walkLoop.update(delta); + if (this.params.viewMode === 'third') { + sceneLoop.updateThirdPersonCharacter(walkLoop.getCharacterState(), delta); + } + sceneLoop.updateCamera(walkLoop.getCameraState()); + if (scheme.splatMode === 'lod') { + sceneLoop.tickLod(); + } + + if (this.hideLoadingOnFrame) { + this.hideLoadingOnFrame = false; + this.ctx.loading.hide(); + } + return true; + } + + /** Reload splats or LOD, avatar, walk mode, and voxel collision. */ + private async reloadScene(): Promise { + this.reloadAbort?.abort(); + this.reloadAbort = new AbortController(); + const reloadSignal = this.reloadAbort.signal; + const generation = ++this.reloadGeneration; + const scheme = WALK_DEMO_SCHEMES[this.params.scheme]; + + this.running = false; + this.walk?.disable(); + this.walk = undefined; + this.scene?.dispose(); + this.scene = undefined; + + throwIfAborted(this.ctx.signal); + throwIfAborted(reloadSignal); + this.ctx.control.setOptions({ enabled: false }); + this.ctx.loading.show(); + + const viewer = this.ctx.renderer.viewer; + if (!this.restoredCamera) { + this.restoredCamera = viewer.getCamera(); + } + + const scene = new WalkDemoScene(viewer); + scene.setThirdPersonModelUrl(WALK_THIRD_PERSON_CHARACTER_URLS[this.params.thirdPersonCharacter]); + this.scene = scene; + this.ctx.renderer.resize(); + + try { + if (scheme.splatMode === 'lod') { + if (!scheme.lodMetaUrl) { + throw new Error('[walk] Outdoor scheme is missing lodMetaUrl.'); + } + const metaUrl = scheme.lodMetaUrl; + const metaBaseUrl = new URL( + '.', + new URL(metaUrl, typeof location !== 'undefined' ? location.href : 'http://localhost/'), + ).href; + await scene.loadLodStream( + metaUrl, + reloadSignal, + createWalkOutdoorLodResourceLoader(this.ctx.indexedDB!, metaBaseUrl, reloadSignal), + ); + if (generation !== this.reloadGeneration) { + scene.dispose(); + return; + } + await scene.appendSplatUrls(scheme.staticSplatUrls ?? [], reloadSignal); + if (generation !== this.reloadGeneration) { + scene.dispose(); + return; + } + } else { + await scene.loadSplatUrls(scheme.splatCandidates ?? [], reloadSignal); + } + + if (generation !== this.reloadGeneration) { + scene.dispose(); + return; + } + + this.ctx.renderer.render(); + + this.walk = new ViewerWalkMode(viewer.canvasContainer); + const walk = this.walk; + this.applyThirdPersonCameraToWalk(walk, scheme); + this.applyViewMode(walk, scene); + if (this.params.viewMode === 'third') { + await scene.waitForThirdPersonCharacter(reloadSignal); + } + if (generation !== this.reloadGeneration) { + return; + } + const p = scheme.pose; + walk.startAtPose(new Vector3(p.px, p.py, p.pz), p.yaw, p.pitch); + if (scheme.splatMode === 'lod') { + scene.updateCamera(walk.getCameraState()); + scene.tickLod(); + this.ctx.renderer.render(); + } + + await this.tryLoadCollision(walk, scheme, reloadSignal); + + if (generation !== this.reloadGeneration) { + return; + } + + this.ctx.renderer.resize(); + this.running = true; + this.hideLoadingOnFrame = true; + throwIfAborted(reloadSignal); + } catch (error) { + if (isReloadAbortError(error) || generation !== this.reloadGeneration) { + return; + } + if (scheme.splatMode === 'lod') { + this.scene?.dispose(); + this.scene = undefined; + console.error('[walk] Outdoor LOD reload failed:', error); + return; + } + this.ctx.loading.hide(); + throw error; + } + } + + /** Load voxel collision data if the scene provides it. */ + private async tryLoadCollision(walk: ViewerWalkMode, scheme: WalkDemoScheme, signal: AbortSignal): Promise { + const jsonUrl = scheme.voxelJson; + const binUrl = scheme.voxelBin; + if (jsonUrl && binUrl) { + const [jsonRes, binRes] = await Promise.all([fetch(jsonUrl, { signal }), fetch(binUrl, { signal })]); + if (!jsonRes.ok || !binRes.ok) { + console.warn( + `[walk] Voxel pair not OK (json ${jsonRes.status}, bin ${binRes.status}); walking without collision.`, + ); + return; + } + throwIfAborted(signal); + const metadataText = await jsonRes.text(); + throwIfAborted(signal); + const metadata = JSON.parse(metadataText) as { + gridBounds: { min: number[]; max: number[] }; + voxelResolution: number; + leafSize: number; + treeDepth: number; + nodeCount: number; + leafDataCount: number; + }; + const binBuffer = await binRes.arrayBuffer(); + throwIfAborted(signal); + const binBytes = new Uint8Array(binBuffer); + const allU32 = new Uint32Array(binBytes.buffer, binBytes.byteOffset, Math.floor(binBytes.byteLength / 4)); + const nodeCount = metadata.nodeCount >>> 0; + const leafDataCount = metadata.leafDataCount >>> 0; + if (nodeCount + leafDataCount > allU32.length) { + console.warn('[walk] Voxel binary size mismatch; skipping voxel collision.'); + return; + } + const nodes = allU32.slice(0, nodeCount); + const leafData = allU32.slice(nodeCount, nodeCount + leafDataCount); + walk.loadVoxelCollision(metadata, nodes, leafData); + return; + } + + if (jsonUrl || binUrl) { + console.warn('[walk] Voxel collision needs both voxelJson and voxelBin; walking without collision.'); + return; + } + + console.warn('[walk] No voxel collision configured (set voxelJson+voxelBin); walking without collision.'); + } + + /** Stop walk mode and restore the original runtime camera. */ + dispose(): void { + this.reloadAbort?.abort(); + this.reloadAbort = undefined; + this.reloadGeneration += 1; + this.running = false; + this.walk?.disable(); + this.walk = undefined; + this.scene?.dispose(); + this.scene = undefined; + this.ctx.configPanel.clear(); + const cam = this.restoredCamera; + this.restoredCamera = undefined; + if (cam) { + this.ctx.renderer.viewer.setCamera(cam); + } + } +} diff --git a/website/src/content/manual/assets/3dgs-preset-config/compressed-ply-quantization.png b/website/src/content/manual/assets/3dgs-preset-config/compressed-ply-quantization.png new file mode 100644 index 0000000..032b156 Binary files /dev/null and b/website/src/content/manual/assets/3dgs-preset-config/compressed-ply-quantization.png differ diff --git a/website/src/content/manual/assets/3dgs-preset-config/normalized-falloff-off.png b/website/src/content/manual/assets/3dgs-preset-config/normalized-falloff-off.png new file mode 100644 index 0000000..a21135b Binary files /dev/null and b/website/src/content/manual/assets/3dgs-preset-config/normalized-falloff-off.png differ diff --git a/website/src/content/manual/assets/3dgs-preset-config/normalized-falloff-on.png b/website/src/content/manual/assets/3dgs-preset-config/normalized-falloff-on.png new file mode 100644 index 0000000..54e6f2c Binary files /dev/null and b/website/src/content/manual/assets/3dgs-preset-config/normalized-falloff-on.png differ diff --git a/website/src/content/manual/assets/3dgs-preset-config/preset-extreme-performance-0-result.png b/website/src/content/manual/assets/3dgs-preset-config/preset-extreme-performance-0-result.png new file mode 100644 index 0000000..516821a Binary files /dev/null and b/website/src/content/manual/assets/3dgs-preset-config/preset-extreme-performance-0-result.png differ diff --git a/website/src/content/manual/assets/3dgs-preset-config/preset-extreme-performance-1-result.png b/website/src/content/manual/assets/3dgs-preset-config/preset-extreme-performance-1-result.png new file mode 100644 index 0000000..ade21b1 Binary files /dev/null and b/website/src/content/manual/assets/3dgs-preset-config/preset-extreme-performance-1-result.png differ diff --git a/website/src/content/manual/assets/3dgs-preset-config/preset-max-quality-result.png b/website/src/content/manual/assets/3dgs-preset-config/preset-max-quality-result.png new file mode 100644 index 0000000..37ca644 Binary files /dev/null and b/website/src/content/manual/assets/3dgs-preset-config/preset-max-quality-result.png differ diff --git a/website/src/content/manual/assets/3dgs-preset-config/preset-performance-first-result.png b/website/src/content/manual/assets/3dgs-preset-config/preset-performance-first-result.png new file mode 100644 index 0000000..a9dd29c Binary files /dev/null and b/website/src/content/manual/assets/3dgs-preset-config/preset-performance-first-result.png differ diff --git a/website/src/content/manual/assets/3dgs-preset-config/preset-quality-first-result.png b/website/src/content/manual/assets/3dgs-preset-config/preset-quality-first-result.png new file mode 100644 index 0000000..bf39930 Binary files /dev/null and b/website/src/content/manual/assets/3dgs-preset-config/preset-quality-first-result.png differ diff --git a/website/src/content/manual/assets/basic-concepts/geometry-attributes.png b/website/src/content/manual/assets/basic-concepts/geometry-attributes.png new file mode 100644 index 0000000..3bc2a42 Binary files /dev/null and b/website/src/content/manual/assets/basic-concepts/geometry-attributes.png differ diff --git a/website/src/content/manual/assets/basic-concepts/material-types.png b/website/src/content/manual/assets/basic-concepts/material-types.png new file mode 100644 index 0000000..e5d2518 Binary files /dev/null and b/website/src/content/manual/assets/basic-concepts/material-types.png differ diff --git a/website/src/content/manual/assets/basic-concepts/object3d-hierarchy.png b/website/src/content/manual/assets/basic-concepts/object3d-hierarchy.png new file mode 100644 index 0000000..29cd959 Binary files /dev/null and b/website/src/content/manual/assets/basic-concepts/object3d-hierarchy.png differ diff --git a/website/src/content/manual/assets/basic-concepts/orthographic-camera.png b/website/src/content/manual/assets/basic-concepts/orthographic-camera.png new file mode 100644 index 0000000..11ed281 Binary files /dev/null and b/website/src/content/manual/assets/basic-concepts/orthographic-camera.png differ diff --git a/website/src/content/manual/assets/basic-concepts/perspective-camera.png b/website/src/content/manual/assets/basic-concepts/perspective-camera.png new file mode 100644 index 0000000..39d17b1 Binary files /dev/null and b/website/src/content/manual/assets/basic-concepts/perspective-camera.png differ diff --git a/website/src/content/manual/assets/basic-concepts/render-flow.png b/website/src/content/manual/assets/basic-concepts/render-flow.png new file mode 100644 index 0000000..23abd31 Binary files /dev/null and b/website/src/content/manual/assets/basic-concepts/render-flow.png differ diff --git a/website/src/content/manual/assets/basic-concepts/scene-tree.png b/website/src/content/manual/assets/basic-concepts/scene-tree.png new file mode 100644 index 0000000..b56a302 Binary files /dev/null and b/website/src/content/manual/assets/basic-concepts/scene-tree.png differ diff --git a/website/src/content/manual/assets/basic-concepts/viewport.png b/website/src/content/manual/assets/basic-concepts/viewport.png new file mode 100644 index 0000000..d4d5f9d Binary files /dev/null and b/website/src/content/manual/assets/basic-concepts/viewport.png differ diff --git a/website/src/content/manual/assets/chunk-lod/structure.png b/website/src/content/manual/assets/chunk-lod/structure.png new file mode 100644 index 0000000..be8f378 Binary files /dev/null and b/website/src/content/manual/assets/chunk-lod/structure.png differ diff --git a/website/src/content/manual/assets/getting-started/example.png b/website/src/content/manual/assets/getting-started/example.png new file mode 100644 index 0000000..6cff948 Binary files /dev/null and b/website/src/content/manual/assets/getting-started/example.png differ diff --git a/website/src/content/manual/assets/playground/playground.en-US.jpeg b/website/src/content/manual/assets/playground/playground.en-US.jpeg new file mode 100644 index 0000000..a235421 Binary files /dev/null and b/website/src/content/manual/assets/playground/playground.en-US.jpeg differ diff --git a/website/src/content/manual/assets/playground/playground.zh-CN.jpeg b/website/src/content/manual/assets/playground/playground.zh-CN.jpeg new file mode 100644 index 0000000..d1e4e01 Binary files /dev/null and b/website/src/content/manual/assets/playground/playground.zh-CN.jpeg differ diff --git a/website/src/content/manual/assets/viewer/viewer.en-US.jpeg b/website/src/content/manual/assets/viewer/viewer.en-US.jpeg new file mode 100644 index 0000000..3860078 Binary files /dev/null and b/website/src/content/manual/assets/viewer/viewer.en-US.jpeg differ diff --git a/website/src/content/manual/assets/viewer/viewer.zh-CN.jpeg b/website/src/content/manual/assets/viewer/viewer.zh-CN.jpeg new file mode 100644 index 0000000..99f8079 Binary files /dev/null and b/website/src/content/manual/assets/viewer/viewer.zh-CN.jpeg differ diff --git a/website/src/content/manual/en-US/3dgs-preset-config.md b/website/src/content/manual/en-US/3dgs-preset-config.md new file mode 100644 index 0000000..b7dc966 --- /dev/null +++ b/website/src/content/manual/en-US/3dgs-preset-config.md @@ -0,0 +1,325 @@ +--- +title: 3DGS Preset Config +description: Choose a preset configuration based on the 3DGS data format, precision needs, and performance target. +order: 4 +--- + +## Background + +No single configuration covers every 3DGS scene. Scenes can differ significantly in data precision, file size, GPU memory usage, device performance, and image quality requirements, so choose a configuration set that matches the business target. + +This page summarizes common data formats, `packType` differences, preset options, and the parameters you can tune after choosing a preset. + +## Quick Choice + +Choose the preset according to product constraints first, then tune only the most important parameters. Avoid changing precision, sorting, and blur parameters at the same time at the start. + +| Target Scenario | Recommended Preset | Key Settings | +| --------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------- | +| Image quality first, with strong user hardware | Max Quality | `compressed`, `pack.highPrecisionEnabled`, `composite.highPrecisionEnabled`, `sort.highPrecisionEnabled` | +| Large scenes that can fail under low precision | Quality First | `compressed`, `pack.highPrecisionEnabled` | +| Weaker devices that still need to open large-scale scenes | Balanced | `super-compressed`, `pack.cameraRelativeEnabled` | +| Weaker devices that still need a mostly complete image | Performance First | `super-compressed`, `raster.detailCullingThreshold`, `raster.maxPixelRadius` | +| Very large scenes or very low-end devices | Extreme Performance 0 | `pack.sortedLayoutEnabled`, `sort.minIntervalMs`, more aggressive precision compression | +| Source data is sog and the goal is to open larger scenes | Extreme Performance 1 | `sog`, `pack.precalculateEnabled`, GPU memory usage | + +## 3DGS File Formats + +| Format | Size | Render Quality | Implementation Notes | +| ---------------- | ------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ply` | 100% | Good | High original precision and the largest file size. | +| `compressed ply` | 30%, about 17% after gzip | Good | Uses 256 splats per chunk and is likely spatially partitioned similarly to ksplat. `center`, `quat`, `scale`, and `rgb` are compressed by min/max, rescale, and quantization. SH can be compressed to u8; observed data is about 5 bit. | +| `spz` | 10% | Average | Retains relatively high precision for core splat data, especially `center`, so sharpness loss is lower. SH precision is very low and can cause visible color shifts in fine-detail scenes. | +| `splat` | 14% | Average, not universal | Drops `shN` during compression. Layout: `center.xyz (f32)`, `scale.xyz (f32)`, `color.rgba (u8)`, `quat (u8)`, 32 bytes in total. | +| `ksplat` | 20%-30% | Depends on compression level | Level 0 is uncompressed, level 1 is 16 bit, and level 2 is 8 bit. It spatially clusters splats for local coordinate compression, following a similar approach to compressed ply. | +| `sog` | 5% | Average | Applies PLAS sorting to `center`, `scales`, `quats`, and `sh0(rgba)`, then computes min/max values and quantizes the data. `shN` uses k-means clustering with centroids and labels to restore data while reducing size. Images tend to be blurrier. | + +### compressed ply Quantization Example + +![compressed ply quantization](../assets/3dgs-preset-config/compressed-ply-quantization.png) + +## packType + +`packType` controls the data precision generated when parsing splats. Different settings trade off size, quality, and performance. + +### Compressed + +| Field | Precision | +| --------------- | ------------ | +| `position` | `f32 (3)` | +| `scale` | `f16 (3)` | +| `quat` | `f16 (4)` | +| `color & alpha` | `f16 (4)` | +| `shN` | `s_11_10_11` | + +`Compressed` favors image quality and data precision. Use it for quality-sensitive output, large scenes, or scenes that show artifacts at lower precision. + +### SuperCompressed + +| Field | Precision | +| --------------- | -------------------------------------- | +| `position` | `f16 (3)` | +| `scale` | `u8 (3)` | +| `quat` | `u8 (4)` | +| `color & alpha` | `u8 (4)` | +| `shN` | `sh1 (sint5)`, `sh2` and `sh3 (sint4)` | + +`SuperCompressed` favors file size, memory, and GPU memory control. Use it when resources are constrained, devices are lower-end, or performance is the priority. + +### Sog + +`Sog` is for sog data. It has the smallest size, but the image can look blurrier. Prefer it when the source format is sog and the data has no `shN`, or when extreme scene scale is required. + +## Preset List + +| Preset | Recommended Scenario | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Max Quality | Use when image quality has the highest priority and the device is very powerful. | +| Quality First | Use when image quality matters and device performance is still acceptable. | +| Balanced | Use when image quality requirements are low and device performance is limited, but large scenes still need to be supported. | +| Performance First | Use when image quality requirements are low and device performance is limited. | +| Extreme Performance 0 | Use on extremely low-end devices or for extremely large scenes. | +| Extreme Performance 1 | Use on extremely low-end devices or for extremely large scenes when the source data is sog. Prefer this preset when the condition is met, because it can open larger scenes. | + +### Max Quality + +```typescript +// set parser config +const splatData = await SplatLoader.parseSplatData( + // file type and data + splatFileType, + content, + // compress config + SplatLoader.SplatPackType.Compressed, +); +const splat = await SplatUtils.createSplat(splatData); +splat.autoFreeResourceOnGpuPacked = true; +viewer.getScene().add(splat); + +// update viewer config +setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + highPrecisionEnabled: true, + cameraRelativeEnabled: false, + }, + raster: { + normalizedFalloff: true, + detailCullingThreshold: 0, + }, + sort: { + highPrecisionEnabled: true, + }, + composite: { + enabled: true, + highPrecisionEnabled: true, + }, + }, + }, +}); +``` + +![max quality render result](../assets/3dgs-preset-config/preset-max-quality-result.png) + +### Quality First + +```typescript +// set parser config +const splatData = await SplatLoader.parseSplatData( + // file type and data + splatFileType, + content, + // compress config + SplatLoader.SplatPackType.Compressed, +); +const splat = await SplatUtils.createSplat(splatData); +splat.autoFreeResourceOnGpuPacked = true; +viewer.getScene().add(splat); + +// update viewer config +setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + highPrecisionEnabled: true, + cameraRelativeEnabled: false, + }, + }, + }, +}); +``` + +![quality first render result](../assets/3dgs-preset-config/preset-quality-first-result.png) + +### Balanced + +```typescript +// set parser config +const splatData = await SplatLoader.parseSplatData( + // file type and data + splatFileType, + content, + // compress config + SplatLoader.SplatPackType.SuperCompressed, +); +const splat = await SplatUtils.createSplat(splatData); +viewer.getScene().add(splat); + +// update viewer config +setViewerConfig(viewer, { + pipeline: { + Splatting: {}, + }, +}); +``` + +### Performance First + +```typescript +// set parser config +const splatData = await SplatLoader.parseSplatData( + // file type and data + splatFileType, + content, + // compress config + SplatLoader.SplatPackType.SuperCompressed, +); +const splat = await SplatUtils.createSplat(splatData); +splat.autoFreeResourceOnGpuPacked = true; +viewer.getScene().add(splat); + +// update viewer config +setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + cameraRelativeEnabled: false, + }, + raster: { + maxStdDev: Math.sqrt(5), + }, + }, + }, +}); +``` + +![performance first render result](../assets/3dgs-preset-config/preset-performance-first-result.png) + +### Extreme Performance 0 + +```typescript +// set parser config +const splatData = await SplatLoader.parseSplatData( + // file type and data + splatFileType, + content, + // compress config & sh + SplatLoader.SplatPackType.SuperCompressed, + { + maxShDegree: 0, + }, +); +const splat = await SplatUtils.createSplat(splatData); +splat.autoFreeResourceOnGpuPacked = true; +viewer.getScene().add(splat); + +// update viewer config +setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + precalculateEnabled: false, + cameraRelativeEnabled: false, + sortedLayoutEnabled: true, + }, + raster: { + detailCullingThreshold: 4, + maxStdDev: Math.sqrt(5), + }, + sort: { + minIntervalMs: 160, + }, + }, + }, +}); +``` + +![extreme performance 0 render result](../assets/3dgs-preset-config/preset-extreme-performance-0-result.png) + +### Extreme Performance 1 + +```typescript +// set parser config +const splatData = await SplatLoader.parseSplatData( + // file type and data + SplatFileType.SOG, + content, + // compress config & sh + SplatLoader.SplatPackType.Sog, + { + maxShDegree: 0, + }, +); +const splat = await SplatUtils.createSplat(splatData); +splat.autoFreeResourceOnGpuPacked = true; +viewer.getScene().add(splat); + +// update viewer config +setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + precalculateEnabled: false, + cameraRelativeEnabled: false, + sortedLayoutEnabled: true, + }, + raster: { + detailCullingThreshold: 4, + maxStdDev: Math.sqrt(5), + }, + sort: { + minIntervalMs: 160, + }, + }, + }, +}); +``` + +![extreme performance 1 render result](../assets/3dgs-preset-config/preset-extreme-performance-1-result.png) + +## Custom Configuration + +Presets cannot cover every scene. In real integrations, choose the closest preset as the starting point, then tune a small number of key parameters. +Parameters can be adjusted through the [config](../config/) API: + +```typescript +setViewerConfig(viewer, { + pipeline: { + Splatting: { + // ... options.. + }, + }, +}); +``` + +| Parameter | Purpose | Recommendation | +| -------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `pack.highPrecisionEnabled` | Enables high-precision data merging. | Determines the final data precision used for rendering. Usually enable it for `compressed`; evaluate it per scene for `sog`. | +| `pack.precalculateEnabled` | Enables spherical-harmonic calculation. | Enable it when the data has no `shN` to save performance and GPU memory. | +| `pack.cameraRelativeEnabled` | Enables camera-relative position packing. | If center values are large but the device cannot afford `highPrecisionEnabled`, try enabling it. When enabled, packing can run on demand on the GPU, so disable `autoFreeResourceOnGpuPacked` to avoid repeated texture uploads. LOD data already needs repeated packing, so it can use this path without the same extra cost. | +| `pack.sortedLayoutEnabled` | Enables sorted layout packing. | A performance optimization for large scenes, usually used with `sort.minIntervalMs`. It can often improve performance by 50%-100%, but increases GPU memory usage. | +| `composite.highPrecisionEnabled` | Enables a high-precision render attachment. | Consider enabling it when the scene shows ripple-like banding artifacts, or when quality is important. It increases GPU memory usage. | +| `raster.normalizedFalloff` | Enables normalized Gaussian falloff. | Most scenes show little difference. Do not enable it unless you need the best possible quality. | +| `raster.preBlurAmount` / `raster.blurAmount` | Controls blur parameters. | Non-AA training results usually use `0.3 / 0`; AA training results usually use `0 / 0.3`. Other values are not recommended. | +| `raster.focalAdjustment` | Adjusts splat spread scale. | `2` is closer to the reference result. | +| `raster.detailCullingThreshold` | Approximate detail culling. | Usually in `[0, 4]`. Setting it to `1` usually causes minimal visual loss; the performance gain depends on scene detail. | +| `raster.maxPixelRadius` | Maximum screen-space range covered by a Gaussian. | Default is `1024`; the recommended range is `[128, 1024]`. Too small a value can make the scene look broken. | +| `raster.maxStdDev` | Maximum standard deviation of Gaussian spread. | Should be between `sqrt(5)` and `sqrt(9)`. Larger values cost more performance but improve quality; `sqrt(8)` is usually a practical quality/performance midpoint. | +| `sort.highPrecisionEnabled` | Controls sorting precision. | When enabled, sorting uses float precision. In most rendering scenes, the visual improvement is small. | +| `sort.minIntervalMs` | Minimum interval between sorting operations. | Usually used with `pack.sortedLayoutEnabled`. A common setting is `16 * n`, where `n` is no greater than `10`. | + +### normalizedFalloff Comparison + +![normalizedFalloff off](../assets/3dgs-preset-config/normalized-falloff-off.png) +![normalizedFalloff on](../assets/3dgs-preset-config/normalized-falloff-on.png) diff --git a/website/src/content/manual/en-US/basic-concepts.md b/website/src/content/manual/en-US/basic-concepts.md new file mode 100644 index 0000000..3aa00ad --- /dev/null +++ b/website/src/content/manual/en-US/basic-concepts.md @@ -0,0 +1,141 @@ +--- +title: Aholo-Viewer Basic Concepts +description: Introduces Aholo-Viewer scenes, objects, lights, cameras, and the basic rendering flow. +order: 2 +--- + +## Reading Entry + +This guide provides the core concepts needed to start using Aholo-Viewer. + +## What Is Aholo-Viewer + +Aholo-Viewer is a general-purpose renderer for standard Mesh and 3DGS content. It supports multiple Web graphics APIs, including WebGL and WebGL2. + +## Usage Model + +The basic usage model is to add objects and lights to a scene, configure the scene and camera, and then trigger rendering. + +### Objects + +Objects are three-dimensional entities represented by `Object3D` in Aholo-Viewer. `Object3D` is the base class for scene composition, with the inheritance structure shown below. + +![Object3D inheritance structure](../assets/basic-concepts/object3d-hierarchy.png) + +Core `Object3D` state includes: + +- `parent` and `children`: Parent-child relationships used to organize the scene. An `Object3D` can have at most one `parent`, and child nodes can be added or removed through the `add` and `remove` APIs. +- `position`, `rotation`, and `scale`: Local position, rotation, and scale. These properties produce the object's local-space transform matrix, also called the local matrix. + +When an object has a parent, the local matrix alone does not fully describe its transform in world space. The child object must be combined with the parent's world matrix to produce its own world matrix. The world matrix is the transform that actually represents the object in the scene. + +`Object3D` itself is not renderable. Its subclasses `Splat` and `Drawable` are the base classes for renderable objects: + +- `Splat` is the base renderable object for `3DGS` data and contains complete `3DGS` content. +- `Drawable` is built around `material` and `geometry`. + +### `Splat` + +`Splat` cannot be constructed directly. It has multiple subclasses used for concrete construction paths and different precision requirements. + +- `CompressedSplat`: Regular precision compression for high-precision display. +- `SuperCompressedSplat`: Higher-ratio compression that trades precision for lower rendering cost and better performance. +- `SogSplat`: A direct rendering component designed for the `sog` format. It does not support higher-order spherical harmonics, but it provides a good balance of rendering performance and visual quality. + +### Materials + +`material` describes the visual appearance of an object. Common material types include the lighting-aware `MeshPhongMaterial` and the basic `MeshBasicMaterial`. + +![Common material types](../assets/basic-concepts/material-types.png) + +### Geometry + +`geometry` describes surfaces, lines, or points. The most common geometry is made of triangle faces and is rendered as a `Mesh`. + +Geometry data is stored in attributes. In addition to the `position` attribute, common attributes include: + +- `uv`: Used for texture sampling. +- `normal`: Used for lighting calculations. +- `index`: Reduces duplicated shared-vertex data by indexing into `position`. + +![Geometry attribute organization](../assets/basic-concepts/geometry-attributes.png) + +The inheritance tree also includes `PopBufferGeometry`, which supports geometry described by `PopBuffer`. It supports LOD and should be used together with `PopMesh`. +In typical usage, `PopMesh` behaves similarly to a regular `Mesh`. + +### Lights + +Lights are also `Object3D` instances. Lights and materials together determine how an object appears. + +Common light types include: + +- `DirectionalLight`: Light emitted from an infinitely distant source in a specific direction, similar to sunlight. +- `AmbientLight`: Non-directional ambient light, commonly used to simulate diffuse indirect lighting. + +A common setup uses one `AmbientLight` and four `DirectionalLight` instances from different directions to illuminate the scene. + +Shadows are also related to lights. The `shadow` field on a light controls shadow parameters, and the `castShadow` field on `Drawable` controls whether the object casts shadows. In addition, `planarShadow` is a special planar shadow that does not depend on lights and must be enabled through configuration. + +### Cameras + +Cameras represent the viewpoint in the scene. Common camera types fall into two categories: + +- `PerspectiveCamera`: A perspective camera that follows the near-larger, far-smaller perspective rule. This is the most common camera type. +- `OrthographicCamera`: An orthographic camera that does not apply perspective scaling. + +![Perspective camera diagram](../assets/basic-concepts/perspective-camera.png) + +![Orthographic camera diagram](../assets/basic-concepts/orthographic-camera.png) + +### Scenes + +A scene is also commonly called a scene tree, or `SceneTree`. It is the data source used by final rendering. + +![Scene tree structure](../assets/basic-concepts/scene-tree.png) + +### Viewport + +A viewport is an Aholo-Viewer rendering output unit with its own bounds. A viewer can contain multiple viewports. A viewport provides the following behavior: + +- It can cover the full canvas or a bounded region of the canvas. +- It can own an independent camera. +- It has an independent pipeline configuration. For available options, see [Viewer Config](./config.md). +- When a `Viewer` is created, it contains one default viewport that represents the full canvas. + +![Viewport](../assets/basic-concepts/viewport.png) + +### Internal Rendering Flow + +The user-provided `Config` affects draw-list generation through the Render Pipeline. The generated `DrawcallList` stores the information needed for each draw command. Each drawcall maps to a low-level graphics API call, so drawcall count usually correlates with CPU cost. + +To inspect this flow directly, install the Spector Chrome extension and capture a frame. + +![Aholo-Viewer internal rendering flow](../assets/basic-concepts/render-flow.png) + +### Usage Summary + +A basic render can be summarized as: + +1. Create a `Scene`. +2. Add objects and lights to the scene tree through the `add` API. +3. Configure the scene, camera, and viewer options. +4. Call the `render` API to trigger rendering. + +## Model Support + +In theory, Aholo-Viewer can render any model that can be converted into the Aholo-Viewer scene structure. Aholo-Viewer also provides loaders for common model formats that can be used as needed: + +- gltf-loader: Loads models described by the glTF/glb format. Because Aholo-Viewer currently primarily renders with Phong materials, PBR-based glTF materials may not be converted completely. +- draco-loader: Loads geometry described by Draco. The geometry must then be converted into a structure Aholo-Viewer can recognize. + +## Plugin System + +Aholo-Viewer also provides plugins for additional capabilities, such as data monitoring and animation: + +- Aholo-Viewer-animation: Adds animation support for Aholo-Viewer. It is recommended to construct animated content with gltf-loader. The plugin currently supports skeletal animation and standard property interpolation transforms. + +## Related Links + +- [WebGL Fundamentals](https://webglfundamentals.org/) +- [WebGL2 Fundamentals](https://webgl2fundamentals.org/) +- [WebGPU Fundamentals](https://webgpufundamentals.org/) diff --git a/website/src/content/manual/en-US/chunk-lod.md b/website/src/content/manual/en-US/chunk-lod.md new file mode 100644 index 0000000..2fa4ddd --- /dev/null +++ b/website/src/content/manual/en-US/chunk-lod.md @@ -0,0 +1,81 @@ +--- +title: Chunk LOD +description: Chunk LOD reference. +order: 8 +--- + +## Background + +Rendering large-scale `3DGS` scenes requires far more system resources than most current devices provide. Large `3DGS` scenes are therefore difficult to render directly on common hardware. A `stream + LOD` approach reduces resource requirements by sacrificing detail in less important regions and preserving quality near the visual focus. `@manycore/aholo-viewer` uses a chunked LOD implementation, `chunk-lod`, as its primary LOD solution. LOD data is generated through a post-processing Gaussian fusion pipeline that does not require retraining. + +![chunk-lod](../assets/chunk-lod/structure.png) + +## Generating `chunk-lod` + +`chunk-lod` data used by `@manycore/aholo-viewer` is usually generated with [`@manycore/aholo-splat-transform`](./splat-transform.md). The generation process mainly includes these steps: + +1. Chunk splitting + > Chunks are split primarily with an octree. The default maximum chunk size is `400000` Gaussians. For large scenes, increase it to `800000` or higher to control the final `chunk` count. +2. Gaussian search and fusion + > Inside each chunk, each `lod` level is generated from the previous level. Every level repeatedly searches and merges Gaussians until it reaches the target count. + > + > For levels that retain fewer Gaussians, the remaining Gaussians are enlarged to reduce holes caused by low retained counts. + > + > After fusion, an additional `opacity` culling pass removes Gaussians with poor visibility. + > + > When the Gaussian count is below a threshold, fusion stops. This can make the output count slightly higher than the target, but the deviation is small and reducing the final number of chunks is usually worth it. + > + > To avoid non-terminating processing, each level has a maximum iteration count. If that limit is reached, processing exits even if the target count has not been met. +3. Output processing + > Low-level data is packed together during output. Low-level data is usually about `1%` of the original data, so the amount is small and packing also removes unnecessary chunks. The final output includes `lod-meta.json`, which describes the `chunk-lod` data. + +References: + +- [A Hierarchical 3D Gaussian Representation for Real-Time Rendering of Very Large Datasets](https://repo-sam.inria.fr/fungraph/hierarchical-3d-gaussians/) +- [NanoGS: Training-Free Gaussian Splat Simplification](https://saliteta.github.io/NanoGS/) + +## `lod-meta.json` Format + +```typescript +interface IBox { + min: [number, number, number]; + max: [number, number, number]; +} + +// typings for +interface LodMeta { + magicCode: 2500660; + type: 'lod-splat'; + version: string; + counts: number; + shDegree: number; + levels: number; + files: string[]; + forwardBox: IBox; + permanentFiles: number[]; + tree: Array<{ + bound: IBox; + lods: Array<{ + file: number; + offset: number; + count: number; + }>; + }>; +} +``` + +- `counts`: total Gaussian count at `level 0`. +- `shDegree`: spherical harmonics degree. +- `forwardBox`: bounding box of the space containing roughly `80%` of all Gaussian spheres at `level 0`. +- `files`: file list. +- `permanentFiles`: file indices that must stay resident in memory or GPU memory. +- `tree`: chunk tree for `lod`. + - `tree[i].bound`: chunk bounding box. This box is also computed from an approximate distribution and excludes outliers. + - `tree[i].lods`: LOD data for the chunk. + - `tree[i].lods[j].file`: data file index. + - `tree[i].lods[j].start`: starting Gaussian offset. + - `tree[i].lods[j].count`: Gaussian count. + +## `Lod` Scheduler + +`@manycore/aholo-viewer` provides a complete `chunk-lod` scheduler. See [`LodSplat`](api:SplatUtils.LodSplat) for the API reference and [Streaming LOD](../../examples/splatting-lod-stream/) for an example. diff --git a/website/src/content/manual/en-US/config.md b/website/src/content/manual/en-US/config.md new file mode 100644 index 0000000..b49705b --- /dev/null +++ b/website/src/content/manual/en-US/config.md @@ -0,0 +1,26 @@ +--- +title: Aholo-Viewer Config +description: Reference for ViewerConfig, render modes, and post-processing options. +order: 3 +--- + +## Overview + +`ViewerConfig` controls the default rendering behavior of Aholo-Viewer. + +## Configuration Path + +```typescript +function setViewerConfig(ctx: Viewer | Viewport, config: IViewerConfig); +``` + +### [`PipelineConfig`](api:IPipelineConfig) + +`pipelineConfig` controls all pipeline features. Each pipeline section can be toggled with the `enable` option. + +- [`Background`](api:IBackgroundPluginConfig): Controls background rendering, such as skyboxes and ground grids. +- [`Composite`](api:ICompositePluginConfig): Controls composition before output, usually to optimize multi-view rendering performance. +- [`Splatting`](api:ISplattingPluginConfig): Controls `3DGS` rendering behavior. For detailed parameter guidance, see [3dgs-preset-config](./3dgs-preset-config.md). +- [`TAA`](api:ITaaPluginConfig): Controls static temporal supersampling anti-aliasing. + +[Full Parameter Reference](api:IViewerConfig) diff --git a/website/src/content/manual/en-US/getting-started.md b/website/src/content/manual/en-US/getting-started.md new file mode 100644 index 0000000..6a16157 --- /dev/null +++ b/website/src/content/manual/en-US/getting-started.md @@ -0,0 +1,127 @@ +--- +title: Getting Started +description: Build a minimal Aholo-Viewer application with Vite. +order: 1 +--- + +## Usage with Vite + +### Install Dependencies + +```bash +npm install --save @manycore/aholo-viewer +npm install --save-dev vite typescript # TypeScript is recommended. +``` + +### Create the Application Code + +- `index.html` + + ```html + + + + My first aholo viewer app + + + + + + ``` + +- `index.ts` + + ```javascript + import { + createViewer, + setViewerConfig, + PerspectiveCamera, + BackgroundMode, + Vector3, + Color, + SplatLoader, + SplatUtils, + } from '@manycore/aholo-viewer'; + + const SPLAT_URL = 'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/bear/bear.3d71a266.sog'; + // Create the container and attach it to the page. + const container = document.createElement('div'); + container.style.width = '500px'; + container.style.height = '500px'; + container.style.display = 'block'; + document.body.appendChild(container); + + async function createScene() { + const viewer = createViewer('example-viewer', container, {}); + const camera = new PerspectiveCamera(60, 1, 0.1, 2000); + + const resp = await fetch(SPLAT_URL); + const buffer = await resp.arrayBuffer(); + const data = await SplatLoader.parseSplatData( + SplatLoader.SplatFileType.SOG, + new Uint8Array(buffer), + SplatLoader.SplatPackType.Compressed, + ); + const splat = await SplatUtils.createSplat(data); + + // The splat uses -Y up in OpenCV coordinates. + camera.up.set(0, -1, 0); + camera.position.set(-1.5, -0.5, 0); + camera.lookAt(new Vector3(0, 0, 0)); + + viewer.getScene().add(splat); + viewer.setCamera(camera); + setViewerConfig(viewer, { + pipeline: { + Background: { + background: { + active: BackgroundMode.BasicBackground, + basic: { + color: new Color(0, 0, 0), + }, + }, + ground: { + enabled: false, + }, + }, + Splatting: { + enabled: true, + }, + TAA: { + enabled: false, + }, + }, + }); + + function render() { + viewer.render(); + } + + // Render again when viewer.requestRender is called. + viewer.requestRenderHandler = function () { + requestAnimationFrame(render); + }; + + requestAnimationFrame(render); + } + + createScene(); + ``` + +### Start the Application + +```bash +npx vite +``` + +After Vite starts, open the local URL in your browser: + +``` +VITE v8.0.14 ready in 83 ms + +Local: http://localhost:5173/ +Network: use --host to expose +press h + enter to show help +``` + +![Example](../assets/getting-started/example.png) diff --git a/website/src/content/manual/en-US/physics-collision.md b/website/src/content/manual/en-US/physics-collision.md new file mode 100644 index 0000000..8b2b7e8 --- /dev/null +++ b/website/src/content/manual/en-US/physics-collision.md @@ -0,0 +1,90 @@ +--- +title: Physics Collision +description: Generate voxel or mesh colliders from 3DGS scenes and run ray and capsule collision queries at runtime. +order: 6 +--- + +## Background + +A reconstructed 3DGS scene is a Gaussian point cloud without solid boundaries for walking or collision. The physics collision module turns that space into **queryable collision data**. Voxels or meshes describe floors, walls, occluders, and walkable regions for walk mode, camera avoidance, area limits, and spatial interaction. + +Voxel colliders are produced from 3DGS assets by the `Voxel` task in `splat-transform`. The sections below describe the file format and runtime queries. An optional `collision.glb` mesh may also be provided. + +## Overview + +Voxel data encodes scene occupancy as a **sparse voxel octree (SVO)** for runtime collision and ray tests: + +- **Raycast**: picking, grounding, line-of-sight checks +- **Sphere / capsule**: character depenetration + +Encoding follows the **Laine–Karras** layout shared with [playcanvas/splat-transform](https://github.com/playcanvas/splat-transform) and [playcanvas/supersplat-viewer](https://github.com/playcanvas/supersplat-viewer). + +## Sparse Octree Structure + +The octree subdivides a uniform voxel grid and stores only non-empty regions to compress large scenes. + +**Levels** + +- `treeDepth` levels from root to leaf; the finest voxels have edge length `voxelResolution`. +- Each leaf covers a **4×4×4** block (`leafSize = 4`), i.e. 64 occupancy bits. + +**Node types** (each `uint32` in `nodes`) + +| Type | Meaning | +| ----------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Interior node** | High 8 bits: `childMask` (which octants exist); low 24 bits: index of the first child; sibling indices use popcount. | +| **Solid leaf** | Value `0xFF000000` (`SOLID_LEAF_MARKER`): the entire 4×4×4 block is solid. | +| **Mixed leaf** | `childMask == 0`; low 24 bits point to a 64-bit mask in `leafData` for per-voxel occupancy. | + +**`leafData`** + +- Each mixed leaf uses 2 `uint32` values (`lo`, `hi`), 64 bits total. +- Bit index for `(vx, vy, vz)` in the block: `vx + vy * 4 + vz * 16` (each ∈ [0, 3]). + +**Traversal** + +`nodes` use a compact breadth-first layout; only children in `childMask` are stored. Queries follow **one path** from the root for `treeDepth` levels. + +**Occupancy at one voxel**: world position → `(ix, iy, iz)` → block `(⌊ix/4⌋, …)`. Descend from `nodes[0]`: + +- **Solid leaf**: occupied. +- **Mixed leaf**: test `(ix&3, iy&3, iz&3)` against `leafData`. +- **Interior node**: pick the octant from block coordinates; if missing, empty; else next index is `baseOffset` plus popcount. + +**Ray marching**: **3D DDA** steps through voxels inside the grid bounds; each cell repeats the occupancy query above. + +**Output files** + +- `voxel-meta.json`: grid bounds, voxel size, `treeDepth`, `nodeCount`, `leafDataCount`, etc. +- `voxel.bin`: binary blob with `nodes` then `leafData` (both as `uint32` arrays). +- Optional `collision.glb` mesh. + +## Raycast & Collision Queries + +`splat-transform` only **generates** voxel data. Ray tests and depenetration are implemented at **runtime** after loading the octree. + +**Raycast** + +1. Clip the ray to the grid bounds; +2. **3D DDA**: step to the next voxel face with the smallest parameter `t` along X/Y/Z; +3. For each cell, descend the octree and test occupancy; +4. Return the first solid hit; a miss if the ray exits the bounds. + +Ray direction need not be normalized. Used for grounding, picking, and short obstacle checks. + +**Position occupancy** + +Map a world position to voxel indices and query whether it lies inside geometry, using the procedure above. + +**Sphere / capsule** + +For solid voxels in the bounding volume, measure distance from each cell to the sphere center or capsule axis. If less than the radius, accumulate push-out along the shortest separation. Iterate when resolving multi-contact. + +## Acknowledgements + +The voxel pipeline, octree encoding, and file format primarily reference PlayCanvas open-source projects: + +| Project | URL | Role | +| -------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| **playcanvas/splat-transform** | https://github.com/playcanvas/splat-transform | Voxelization, nav fill/carve, octree export, collision mesh generation | +| **playcanvas/supersplat-viewer** | https://github.com/playcanvas/supersplat-viewer | Runtime voxel collision (raycast, sphere/capsule); see [Issues](https://github.com/playcanvas/supersplat-viewer/issues) | diff --git a/website/src/content/manual/en-US/playground.md b/website/src/content/manual/en-US/playground.md new file mode 100644 index 0000000..b5895c2 --- /dev/null +++ b/website/src/content/manual/en-US/playground.md @@ -0,0 +1,70 @@ +--- +title: Playground +description: Usage guide for Playground. +order: 6 +--- + +## Overview + +[`Playground`](../../playground/) is a lightweight online editor for validating code with `@manycore/aholo-viewer` directly. The editor is built on [`monaco-editor`](https://github.com/microsoft/monaco-editor) and provides full `typescript` support with basic code completion. + +![playground](../assets/playground/playground.en-US.jpeg) + +## Usage + +Basic code template: + +```typescript +import { type Viewer } from '@manycore/aholo-viewer'; +import type { RenderRuntime } from '../../client/render-runtime'; + +// typing of `RenderRuntime` +// interface RenderRuntime { +// // core renderer +// renderer: RuntimeRenderer; +// // camera interaction controller +// control: CameraControl; +// // loading fame controller +// loading: RuntimeLoadingController; +// // a config panel component base on tweakpane +// configPanel: RuntimeConfigPanel; +// // indexed db cache storage +// indexedDB: RuntimeIndexedDBStorage; +// // abort signal dispatcher +// signal: AbortSignal; +// } + +export default async function runner({ renderer, control, loading, configPanel, indexedDB, signal }: RenderRuntime) { + const const { scene, viewer } = renderer; + // do work with scene & viewer + // .... + // use `throwIfAborted(signal)` to check whether abort requested + // use `loading.show(info)` to update the loading to what ever you want to indicate which step is running + + // config frame call back, return a boolean to indicate whether anything updated + renderer.frame(({ delta }) => { + const cameraUpdated = control.update(delta); + let animationUpdated = false; + // update animation here... + return cameraUpdated || animationUpdated; + }); + + // request next frame render + renderer.render(); + // hide loading frame + loading.hide(); +} + +function throwIfAborted(signal: AbortSignal) { + if (signal.aborted) { + throw new DOMException('The splatting basic sample load was aborted.', 'AbortError'); + } +} +``` + +In addition to the basic template, some [examples](../../examples/) can be opened from the button at the bottom of each example page or from the preset selector below `playground`. After editing, click **Run** to execute the code and view the result. Edited code can also be shared by copying the generated link directly. + +## Notes + +- `playground` currently does not support importing third-party libraries. +- To report issues related to `@manycore/aholo-viewer`, we recommend building a minimal reproduction in `playground` and submitting it with the [issue](https://github.com/manycoretech/aholo-viewer/issues). diff --git a/website/src/content/manual/en-US/splat-transform.md b/website/src/content/manual/en-US/splat-transform.md new file mode 100644 index 0000000..75bf9d8 --- /dev/null +++ b/website/src/content/manual/en-US/splat-transform.md @@ -0,0 +1,454 @@ +--- +title: splat-transform +description: Usage guide for splat-transform. +order: 5 +--- + +## Background + +`splat-transform` is a 3DGS processing tool for Aholo Viewer. Use it for format conversion, data simplification, LOD generation, and voxel collider generation. + +## Environment Requirements + +- Node.js >= 22.22.1 +- Windows: Windows 22H2+ x86_64 with a D3D12 or Vulkan-compatible GPU. A discrete GPU is recommended when GPU features are enabled. +- Linux: x86_64, ARM64, glibc >= 2.34, libstdc++ >= 3.4.30, and a Vulkan-compatible GPU. A discrete GPU is recommended when GPU features are enabled. +- macOS: apple silicon ARM64 only. + +### GPU-Required Features + +- SOG generation. +- Voxel generation when `backend` is set to `gpu`. + +## Format Notes + +### Input Formats + +- `ply` +- `sog` +- `ksplat` +- `splat` +- `spz` +- `lcc` +- `esz` +- `compressed.ply`, the supersplat compressed ply format +- `meta.json`, unpacked sog metadata + +### Output Formats + +- `ply` +- `spz` +- `uspz`, an spz file without gzip compression +- `splat` +- `sog` +- `esz` + +### modify Format + +```javascript +{ + isRowMatrix: boolean; // Whether transforms use row matrices. Defaults to true. + transform: number[]; // Model-level transform. + deletedIndices: number[]; // Deleted indices in bitmap form. + indicesTransform: Array<{ indices: number[]; transform: number[] }>; // Local transform list. +} +``` + +## Usage + +### Installation + +```bash +npm install @manycore/aholo-splat-transform -g +``` + +### CLI Mode + +```bash +splat-transform create # Convert 3DGS formats. +splat-transform lod:auto --ratio # Simplify 3DGS data to the specified ratio [0-1]. +splat-transform lod:auto-chunk --type --max-chunk-counts # Generate schedulable multi-level LOD data. --max-chunk-counts controls the maximum chunk size. +``` + +### Pipeline Mode (Recommended) + +```bash +splat-transform pipeline.json +``` + +#### Pipeline Descriptor (pipeline.json) + +```json +{ + "version": 1, + "tasks": [ + { + "id": "0", + "type": "Read", + "config": { "inputs": ["a.ply"], "output": "cache0" } + }, + { + "id": "1", + "type": "AutoChunkLod", + "config": { "input": "cache0", "output": "cache0", "type": "spz" } + }, + { + "id": "2", + "type": "Write", + "config": { "input": "cache0", "output": "a-lod" } + } + ] +} +``` + +#### Task + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NamePurposeParameterTypeRequired (Default)Description
ReadReads multiple Gaussian files and merges them into one SplatData object.inputsstring[]YInput file paths.
outputstringYResource key to write.
maxShDegreenumber
0..=3
N(3)Maximum spherical harmonics degree.
WriteWrites a SplatData object to disk in the specified format.inputstringYResource key to read.
outputstringYOutput resource path.
compressLevelnumber
0..=9
N(6)gzip compression level.
enableMortonSortbooleanN(true)Enables Morton sorting.
ModifyModifies a SplatData object.inputstringYResource key to read.
outputstringYResource key to write.
modifyPathsstring[]N([])modify JSON file paths. See modify Format.
AutoLodGenerates fused Gaussian output.inputstringYResource key to read.
outputstringYResource key to write.
countsnumberN(Infinity)Maximum retained count.
rationumber
0..=1
N(0.3)Maximum retained ratio.
AutoChunkLodGenerates chunked fused Gaussian output for use with the LOD scheduler module.inputstringYResource key to read.
outputstringYResource key to write.
typestringYChunk file type. See Output Formats.
forceSpzFormatThresholdnumberN(0)Because low-count sog chunks can compress poorly, chunks below this threshold are forced to spz. A practical starting value is 200000.
maxChunkCountsnumberN(400000)Maximum number of Gaussian points per chunk.
levels +
+        
+Array<{
+  precision: number,
+  scaleBoost: number
+}>
+        
+      
+
+ N +
+        
+[
+  { precision: 1.0, scaleBoost: 1 },
+  { precision: 0.5, scaleBoost: 1 },
+  { precision: 0.25, scaleBoost: 1 },
+  { precision: 0.05, scaleBoost: 1.01 },
+  { precision: 0.01, scaleBoost: 1.02 },
+]
+        
+      
+
LOD level precision settings.
VoxelGenerates voxel colliders.inputstringYResource key to read.
outputstringYOutput file path.
voxelResolutionnumberN(0.05)Voxel size.
opacityCutoffnumberN(0.1)Voxel filtering threshold. Higher values cull voxels more aggressively. Increase it when the scene has many floating artifacts.
backend + string +
    +
  • cpu
  • +
  • gpu
  • +
+
N(gpu)Generation backend. Defaults to gpu; cpu is available but significantly slower. Results can differ slightly between backends.
box +
+        
+{
+  minCorner: [number, number, number],
+  maxCorner: [number, number, number]
+}
+        
+      
+
+ N +
+        
+{
+  minCorner: [-100, -100, -100],
+  maxCorner: [100, 100, 100]
+}
+        
+      
+
Scene box limit. Outliers can severely affect voxelization performance and produce meaningless output, so this constrains the voxel generation range.
navCapsule +
+        
+{
+  height: number,
+  radius: number
+}
+        
+      
+
N(null) +Both fields are used for navigation simplification. navCapsule sets the navigation body height and radius, and navSeed sets the navigation start center. + +When enabled, voxels are simplified by the reachable range of the navigation body. This can optimize voxel output, but the feature is still incomplete and may have side effects in some cases, so it is disabled by default.
navSeed +
+        
+{
+  x: number,
+  y: number,
+  z: number
+}
+        
+      
+
N(null)
+ +#### Examples + +Apply modifications to `a.ply` and `b.ply`, then write `c.spz`: + +```json +{ + "version": 1, + "tasks": [ + { + "id": "0", + "type": "Read", + "config": { "inputs": ["a.ply", "b.ply"], "output": "cache0" } + }, + { + "id": "1", + "type": "Modify", + "config": { "input": "cache0", "output": "cache0", "modifyPaths": ["a.json", "b.json"] } + }, + { + "id": "2", + "type": "Write", + "config": { "input": "cache0", "output": "c.spz" } + } + ] +} +``` + +Apply a modification to `a.ply`, then generate auto chunk LOD output: + +```json +{ + "version": 1, + "tasks": [ + { + "id": "0", + "type": "Read", + "config": { "inputs": ["a.ply"], "output": "cache0" } + }, + { + "id": "1", + "type": "Modify", + "config": { "input": "cache0", "output": "cache0", "modifyPaths": ["a.json"] } + }, + { + "id": "2", + "type": "AutoChunkLod", + "config": { "input": "cache0", "output": "cache0", "type": "spz" } + }, + { + "id": "3", + "type": "Write", + "config": { "input": "cache0", "output": "a-lod" } + } + ] +} +``` + +Generate voxel colliders: + +```json +{ + "version": 1, + "tasks": [ + { + "id": "0", + "type": "Read", + "config": { "inputs": ["input.ply"], "output": "cache0" } + }, + { + "id": "1", + "type": "Voxel", + "config": { + "input": "cache0", + "output": "voxel-output", + "voxelResolution": 0.05, + "opacityCutoff": 0.1, + "navCapsule": { "height": 1.4, "radius": 0.2 }, + "navSeed": { "x": 0, "y": 0, "z": 0 } + } + } + ] +} +``` + +## Notes + +- A reasonably powerful discrete GPU is recommended when generating `SOG` or generating `Voxel` data with `GPU`. Converting large datasets to `SOG` may require more than 10 GB of GPU memory. +- When generating `chunk-lod` with `AutoChunkLod` or `lod:auto-chunk`, `spz` or `esz` output is recommended. After chunking and multi-level `lod` generation, some chunks can contain very little data. `sog` compresses these small chunks less effectively than `spz`. You can also use `forceSpzFormatThreshold` to force chunks below a chosen count to `spz`. +- `chunk-lod` generation is resource-intensive, so use a relatively powerful machine. For large datasets, memory >= 32 GB and CPU cores >= 16, including hyper-threading, are recommended. If generation cannot complete directly, split the data into coarse chunks first, then generate and merge `lod-meta.json` files. Merging can be done with the `merge-lod` command from `@manycore/aholo-splat-dev-server@>=1.0.1`. diff --git a/website/src/content/manual/en-US/viewer.md b/website/src/content/manual/en-US/viewer.md new file mode 100644 index 0000000..d88754e --- /dev/null +++ b/website/src/content/manual/en-US/viewer.md @@ -0,0 +1,73 @@ +--- +title: Viewer +description: Usage guide for Viewer. +order: 7 +--- + +## Overview + +[`Viewer`](../../viewer/) is a quick online viewer built with `@manycore/aholo-viewer`. It can import any supported asset and display the result directly. It also supports LOD-format data generated by [`@manycore/aholo-splat-transform`](./splat-transform.md). + +> Browsers cannot access local files directly. For `chunk-lod` data, host the files with a server or `CDN`. You can use `@manycore/aholo-splat-dev-server` for quick local hosting. + +![viewer](../assets/viewer/viewer.en-US.jpeg) + +## Usage Guide + +- The left panel imports supported data formats and configures the camera. + > It supports local files, URLs, and clipboard data (URLs). Local data only supports a complete single asset and does not support `chunk-lod` data. + > + > The camera provides 3 switchable coordinate systems: OpenCV (-Y up, point-cloud coordinates), OpenGL (+Y up, common model coordinates), and Aholo (+Z up, the default coordinate system for Aholo platform data). +- The right panel controls `3DGS` features and pipeline configuration. See [`3dgs-preset-config`](./3dgs-preset-config.md). + +## Local Quick Validation Platform + +### Install Dependencies + +```bash +npm install @manycore/aholo-splat-transform -g +npm install @manycore/aholo-splat-dev-server -g +``` + +### Usage + +For the full `@manycore/aholo-splat-transform` guide, see [splat-transform](./splat-transform.md). + +### `@manycore/aholo-splat-dev-server` + +`@manycore/aholo-splat-dev-server` provides two executable commands. `splat-dev-server` starts a local hosting service for assets used by `Viewer`. `merge-lod` merges multiple `lod-meta.json` files and their related assets into one new `lod-meta.json`, which is useful for combining chunked processing results back into one large `3DGS` `chunk-lod` dataset. + +- `splat-dev-server`: starts a server for quickly hosting assets used by `Viewer`. + > ```bash + > splat-dev-server [options] + > Options: + > --help Show help [boolean] + > --version Show version number [boolean] + > -a, --address Address to listen [string] [default: "127.0.0.1"] + > -p, --port Port to listen [number] [default: 3000] + > ``` + > + > After startup, you should see output like this: + > + > ```bash + > ======================================== + > Splat dev server started + > Host: 127.0.0.1:3000 + > Root: ./chunk-lod + > Base URL: http://127.0.0.1:3000 + > ======================================== + > ``` + > + > Access resources under `Root` through the `Base URL`, then enter that URL into the corresponding `Viewer` field. + > + > **Note: When used in `Viewer`, the browser may ask for permission. Allow the request. `Viewer` does not access unhosted resources or collect any user information.** +- `merge-lod`: merges multiple `chunk-lod` generation outputs into one result. It is usually used after generating `chunk-lod` in separate chunks and then merging them into a complete large `chunk-lod` dataset. + > ```bash + > merge-lod -i -o + > + > Options: + > --help Show help [boolean] + > --version Show version number [boolean] + > -i, --input Input lod meta files(lod-meta.json) [array] [required] + > -o, --output Output directory [string] [required] + > ``` diff --git a/website/src/content/manual/zh-CN/3dgs-preset-config.md b/website/src/content/manual/zh-CN/3dgs-preset-config.md new file mode 100644 index 0000000..f9010c5 --- /dev/null +++ b/website/src/content/manual/zh-CN/3dgs-preset-config.md @@ -0,0 +1,325 @@ +--- +title: 3DGS Preset Config +description: 根据 3DGS 数据格式、精度和性能目标选择 preset 配置。 +order: 4 +--- + +## 背景 + +单一配置无法覆盖所有 3DGS 场景。不同场景在数据精度、体积、显存、机器性能和画质要求上差异较大,因此需要按业务目标选择合理的配置组合。 + +这篇文档整理常见数据格式、`packType` 差异、preset 列表,以及可在 preset 基础上继续微调的参数。 + +## 快速选择 + +先按业务约束选择 preset,再只微调最关键的参数。不要一开始就同时调整多个精度、排序和模糊参数。 + +| 场景目标 | 建议起点 | 需要关注 | +| ---------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------- | +| 画质优先,用户设备性能强 | 极限效果 | `compressed`、`pack.highPrecisionEnabled`、`composite.highPrecisionEnabled`、`sort.highPrecisionEnabled` | +| 大场景且低精度容易出问题 | 效果优先 | `compressed`、`pack.highPrecisionEnabled` | +| 设备较弱但需要打开大规模方案 | 均衡 | `super-compressed`、`cameraRelativeEnabled` | +| 设备较弱但仍需要较完整画面 | 性能优先 | `super-compressed`、`raster.detailCullingThreshold`、`raster.maxPixelRadius` | +| 极大场景或极低设备配置 | 极限性能 0 | `pack.sortedLayoutEnabled`、`sort.minIntervalMs`、更激进的精度压缩 | +| 原始数据为 sog,且目标是打开更大规模场景 | 极限性能 1 | `sog`、`pack.precalculateEnabled`、显存占用 | + +## 3DGS 文件格式 + +| 数据格式 | 体积 | 渲染质量 | 实现细节 | +| ---------------- | ------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ply` | 100% | 好 | 原始精度高,体积最大。 | +| `compressed ply` | 30%,gzip 后约 17% | 较好 | 以 256 个 splat 为 chunk,大概率与 ksplat 类似按空间划分。`center`、`quat`、`scale`、`rgb` 会计算 min/max,并通过 rescale 与量化压缩。SH 可压缩到 u8,实测中约为 5 bit。 | +| `spz` | 10% | 一般 | 对 splat 核心数据,尤其是 `center`,精度保留较高,因此锐利度损失较低;SH 精度很低,细微场景容易出现明显变色。 | +| `splat` | 14% | 一般,不通用 | 压缩时删除 `shN` 数据。数据排布为 `center.xyz (f32)`、`scale.xyz (f32)`、`color.rgba (u8)`、`quat (u8)`,共 32 字节。 | +| `ksplat` | 20%-30% | 依赖压缩 level | level 0 不压缩,level 1 为 16 bit,level 2 为 8 bit。会按空间聚类进行局部坐标压缩,整体思路与 compressed 类似。 | +| `sog` | 5% | 一般 | 对 `center`、`scales`、`quats`、`sh0(rgba)` 做 PLAS 排序,再计算 min/max 并量化。`shN` 会做 k-means 聚类,用 centroids 和 labels 恢复数据,以提高精度并减少体积。画面会相对模糊。 | + +### compressed ply 量化示意 + +![compressed ply 量化示意](../assets/3dgs-preset-config/compressed-ply-quantization.png) + +## packType + +`packType` 控制解析 splats 时生成的数据精度。不同配置会在体积、质量和性能之间做取舍。 + +### Compressed + +| 字段 | 精度 | +| --------------- | ------------ | +| `position` | `f32 (3)` | +| `scale` | `f16 (3)` | +| `quat` | `f16 (4)` | +| `color & alpha` | `f16 (4)` | +| `shN` | `s_11_10_11` | + +`Compressed` 更偏向画质与数据精度,适合质量要求高、场景尺寸较大或低精度下容易出现异常的场景。 + +### SuperCompressed + +| 字段 | 精度 | +| --------------- | ------------------------------------- | +| `position` | `f16 (3)` | +| `scale` | `u8 (3)` | +| `quat` | `u8 (4)` | +| `color & alpha` | `u8 (4)` | +| `shN` | `sh1 (sint5)`,`sh2` 和 `sh3 (sint4)` | + +`SuperCompressed` 更偏向体积、内存和显存控制。它适合资源紧张、设备配置较低或性能优先的场景。 + +### Sog + +`Sog` 面向 sog 数据格式。它的体积最小,但画面可能更模糊。原始格式为 sog,且没有 `shN` 或对极限场景规模有要求时,可以优先考虑。 + +## Preset List + +| 预设名 | 适用场景 | +| ---------- | ----------------------------------------------------------------------------------------------------------- | +| 极限效果 | 对效果有最高要求,设备性能很强。 | +| 效果优先 | 对效果有一定要求,设备性能尚可。 | +| 均衡 | 对效果要求低,设备性能较低,但需要支持大场景。 | +| 性能优先 | 对效果要求低,设备性能较低。 | +| 极限性能 0 | 机器配置极低,或场景规模极大时。 | +| 极限性能 1 | 机器配置极低或场景规模极大,并且原始数据格式为 sog 时。满足条件时更建议使用这个配置,可打开更大的场景规模。 | + +### 极限效果 + +```typescript +// set parser config +const splatData = await SplatLoader.parseSplatData( + // file type and data + splatFileType, + content, + // compress config + SplatLoader.SplatPackType.Compressed, +); +const splat = await SplatUtils.createSplat(splatData); +splat.autoFreeResourceOnGpuPacked = true; +viewer.getScene().add(splat); + +// update viewer config +setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + highPrecisionEnabled: true, + cameraRelativeEnabled: false, + }, + raster: { + normalizedFalloff: true, + detailCullingThreshold: 0, + }, + sort: { + highPrecisionEnabled: true, + }, + composite: { + enabled: true, + highPrecisionEnabled: true, + }, + }, + }, +}); +``` + +![极限效果渲染结果](../assets/3dgs-preset-config/preset-max-quality-result.png) + +### 效果优先 + +```typescript +// set parser config +const splatData = await SplatLoader.parseSplatData( + // file type and data + splatFileType, + content, + // compress config + SplatLoader.SplatPackType.Compressed, +); +const splat = await SplatUtils.createSplat(splatData); +splat.autoFreeResourceOnGpuPacked = true; +viewer.getScene().add(splat); + +// update viewer config +setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + highPrecisionEnabled: true, + cameraRelativeEnabled: false, + }, + }, + }, +}); +``` + +![效果优先渲染结果](../assets/3dgs-preset-config/preset-quality-first-result.png) + +### 均衡 + +```typescript +// set parser config +const splatData = await SplatLoader.parseSplatData( + // file type and data + splatFileType, + content, + // compress config + SplatLoader.SplatPackType.SuperCompressed, +); +const splat = await SplatUtils.createSplat(splatData); +viewer.getScene().add(splat); + +// update viewer config +setViewerConfig(viewer, { + pipeline: { + Splatting: {}, + }, +}); +``` + +### 性能优先 + +```typescript +// set parser config +const splatData = await SplatLoader.parseSplatData( + // file type and data + splatFileType, + content, + // compress config + SplatLoader.SplatPackType.SuperCompressed, +); +const splat = await SplatUtils.createSplat(splatData); +splat.autoFreeResourceOnGpuPacked = true; +viewer.getScene().add(splat); + +// update viewer config +setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + cameraRelativeEnabled: false, + }, + raster: { + maxStdDev: Math.sqrt(5), + }, + }, + }, +}); +``` + +![性能优先渲染结果](../assets/3dgs-preset-config/preset-performance-first-result.png) + +### 极限性能 0 + +```typescript +// set parser config +const splatData = await SplatLoader.parseSplatData( + // file type and data + splatFileType, + content, + // compress config & sh + SplatLoader.SplatPackType.SuperCompressed, + { + maxShDegree: 0, + }, +); +const splat = await SplatUtils.createSplat(splatData); +splat.autoFreeResourceOnGpuPacked = true; +viewer.getScene().add(splat); + +// update viewer config +setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + precalculateEnabled: false, + cameraRelativeEnabled: false, + sortedLayoutEnabled: true, + }, + raster: { + detailCullingThreshold: 4, + maxStdDev: Math.sqrt(5), + }, + sort: { + minIntervalMs: 160, + }, + }, + }, +}); +``` + +![极限性能 0 渲染结果](../assets/3dgs-preset-config/preset-extreme-performance-0-result.png) + +### 极限性能 1 + +```typescript +// set parser config +const splatData = await SplatLoader.parseSplatData( + // file type and data + SplatFileType.SOG, + content, + // compress config & sh + SplatLoader.SplatPackType.Sog, + { + maxShDegree: 0, + }, +); +const splat = await SplatUtils.createSplat(splatData); +splat.autoFreeResourceOnGpuPacked = true; +viewer.getScene().add(splat); + +// update viewer config +setViewerConfig(viewer, { + pipeline: { + Splatting: { + pack: { + precalculateEnabled: false, + cameraRelativeEnabled: false, + sortedLayoutEnabled: true, + }, + raster: { + detailCullingThreshold: 4, + maxStdDev: Math.sqrt(5), + }, + sort: { + minIntervalMs: 160, + }, + }, + }, +}); +``` + +![极限性能 1 渲染结果](../assets/3dgs-preset-config/preset-extreme-performance-1-result.png) + +## 定制化配置 + +Preset 无法覆盖所有场景。实际接入时可以选择最接近目标的 preset 作为起点,再调整少量关键参数。 +参数可以通过[config](./config.md)接口进行调整,示例如下 + +```typescript +setViewerConfig(viewer, { + pipeline: { + Splatting: { + // ... options.. + }, + }, +}); +``` + +| 参数名 | 作用 | 建议 | +| -------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pack.highPrecisionEnabled` | 是否开启高精度数据合并。 | 决定最终用于渲染的数据精度。`compressed` 下通常需要开启;`sog` 下需要看具体场景。 | +| `pack.precalculateEnabled` | 是否开启球谐光计算。 | 如果数据本身没有 `shN`,建议开启,以节约性能和显存。 | +| `pack.cameraRelativeEnabled` | 是否开启相机相对位置计算。 | 如果center值较大,但设备性能不足以支持`highPrecisionEnabled`的启用,可以尝试开启,但需注意开启后会在gpu上按需pack数据,应该关掉autoFreeResourceOnGpuPacked防止贴图数据的重复上传,但如果是lod数据那由于实现本身就需要反复pack数据则可以免费做这件事。 | +| `pack.sortedLayoutEnabled` | 是否开启 sorted layout。 | 大场景性能优化手段,通常与 `sort.minIntervalMs` 一起设置。性能通常能提升 50%-100%,但会增加显存开销。 | +| `composite.highPrecisionEnabled` | 是否开启高精度渲染缓冲。 | 场景中出现水波扩散状条纹时可以考虑开启;对效果要求较高时也可开启;会增加显存开销。 | +| `raster.normalizedFalloff` | 开启高斯函数结果归一化曲线。 | 大多数场景差异不明显。除非追求最佳效果,否则不建议开启。 | +| `raster.preBlurAmount` / `raster.blurAmount` | 控制模糊参数。 | 非 AA 训练结果通常使用 `0.3 / 0`;AA 训练结果通常使用 `0 / 0.3`。不建议设置其他值。 | +| `raster.focalAdjustment` | 调整 splat 扩散缩放。 | 设置为 `2` 更接近参考结果。 | +| `raster.detailCullingThreshold` | 近似细节剔除。 | 通常值在 `[0, 4]`。一般设置 `1` 对画面损失极小,具体性能收益取决于方案精细程度。 | +| `raster.maxPixelRadius` | 高斯覆盖屏幕的最大像素范围。 | 默认 `1024`,建议在 `[128, 1024]` 之间。过小可能导致方案破碎。 | +| `raster.maxStdDev` | 高斯扩散的最大标准差。 | 范围应在 `sqrt(5)` 到 `sqrt(9)`。值越大性能越差,但效果越好;`sqrt(8)` 通常是效果和性能之间的折中点。 | +| `sort.highPrecisionEnabled` | 设置排序精度。 | 开启后用 float 进行排序,绝大多数渲染场景下对于效果的提升较小。 | +| `sort.minIntervalMs` | 设置排序最小发生间隔。 | 通常与 `pack.sortedLayoutEnabled` 配合使用。常见设置为 `16 * n`,且 `n` 不大于 `10`。 | + +### normalizedFalloff 对比 + +![normalizedFalloff 关闭](../assets/3dgs-preset-config/normalized-falloff-off.png) +![normalizedFalloff 开启](../assets/3dgs-preset-config/normalized-falloff-on.png) diff --git a/website/src/content/manual/zh-CN/basic-concepts.md b/website/src/content/manual/zh-CN/basic-concepts.md new file mode 100644 index 0000000..f5000c1 --- /dev/null +++ b/website/src/content/manual/zh-CN/basic-concepts.md @@ -0,0 +1,141 @@ +--- +title: Aholo-Viewer 基础概念 +description: 介绍 Aholo-Viewer 的场景、物体、灯光、相机和基础渲染流程。 +order: 2 +--- + +## 阅读入口 + +这篇文档用于快速建立 Aholo-Viewer 的基础概念 + +## 什么是 Aholo-Viewer + +Aholo-Viewer是支持常规Mesh和3DGS渲染通用渲染器,兼容多种Web图形接口(WebGL, WebGL2)。 + +## 如何使用 + +Aholo-Viewer 的基本使用模型是:把物体和灯光放入场景,设置场景和相机,然后触发渲染。 + +### 物体 + +物体主要指三维对象,在 Aholo-Viewer 中对应 `Object3D`。`Object3D` 是组成场景的基类,继承结构如下。 + +![Object3D 继承结构](../assets/basic-concepts/object3d-hierarchy.png) + +`Object3D` 的核心信息包括: + +- `parent` 和 `children`:与场景组织相关的父子节点关系。一个 `Object3D` 最多只能有一个 `parent`,可通过 `add` 和 `remove` 接口添加或删除子节点。 +- `position`、`rotation` 和 `scale`:分别描述局部位置、旋转和缩放。这些属性可以计算出对象的局部空间变换矩阵,也就是 local matrix。 + +如果对象存在父节点,仅靠 local matrix 不能完整表示它在世界空间中的变换。子对象需要结合父对象的 world matrix,才能得到自己的 world matrix。world matrix 才真正反映对象在场景中的空间变换。 + +`Object3D` 本身不能被渲染。它的子类 `Splat`和`Drawable`才是可渲染对象的基类: + +- `Splat`是`3DGS`基础可渲染对象,包含完整的`3DGS`数据 +- `Drawable` 的核心是 `material` 和 `geometry`,也就是材质和几何。 + +### `Splat` + +`Splat`本身无法被直接构造,包含多种子类用于实际构造,适用于不同的精度场景。 + +- `CompressedSplat`: 常规精度压缩,用于高精度显示。 +- `SuperCompressedSplat`: 超级精度压缩,以损失精度的为代价,降低整体渲染开销,提升性能。 +- `SogSplat`: 专为`sog`格式设计的直接渲染组件,不支持高阶球协,但能获得比较好的渲染性能和显示效果。 + +### 材质 + +`material` 描述物体外观。常见材质包括受光照影响的 `MeshPhongMaterial`,以及基础材质 `MeshBasicMaterial`。 + +![常见材质类型](../assets/basic-concepts/material-types.png) + +### 几何 + +`geometry` 描述面片、线或点。最常见的是由三角形面片组成的几何,对应 `Mesh`。 + +几何数据实际存放在 attribute 中。除表示位置的 `position` 外,通常还有: + +- `uv`:用于纹理采样。 +- `normal`:用于光照计算。 +- `index`:通过索引 `position` 减少共享顶点带来的数据冗余。 + +![几何 attribute 组织](../assets/basic-concepts/geometry-attributes.png) + +继承链中存在`PopBufferGeometry`,用于支持`PopBuffer`描述的几何,支持Lod,需要配合`PopMesh`使用。 +`PopMesh`一般与普通的`Mesh`不存在什么区别 + +### 灯光 + +灯光也是一种 `Object3D`。灯光会与材质共同决定物体的外观表现。 + +常用灯光包括: + +- `DirectionalLight`:表示来自无限远处、具有特定方向的光,类似阳光。 +- `AmbientLight`:表示无方向环境光,常用于模拟漫反射。 + +一种常见组合是使用一个 `AmbientLight` 和四个不同方向的 `DirectionalLight` 照亮场景。 + +阴影也与灯光相关。灯光上的 `shadow` 字段用于控制阴影参数,`Drawable` 上的 `castShadow` 字段表示是否投射阴影。除此之外,`planarShadow` 是一种特殊的平面阴影,不依赖灯光,需要在配置中开启。 + +### 摄像机 + +摄像机表示场景中的观察点。常见摄像机分为两类: + +- `PerspectiveCamera`:透视相机,遵循近大远小原则,是最常见的相机类型。 +- `OrthographicCamera`:正交相机,不产生透视缩放。 + +![透视相机示意](../assets/basic-concepts/perspective-camera.png) + +![正交相机示意](../assets/basic-concepts/orthographic-camera.png) + +### 场景 + +场景通常也称为场景树,也就是 `SceneTree`。它是最终渲染绘制的数据来源。 + +![场景树结构](../assets/basic-concepts/scene-tree.png) + +### Viewport + +viewport为Aholo-Viewer渲染输出的单元,包含边界描述,一个viewer可以拥有多个viewport,viewport具有以下功能: + +- 可以是完整的canvas,也可以是canvas的一部分,通过边界描述 +- viewport可以拥有完全独立的相机 +- 有完整的独立管线配置, 可配置项可以参考[Viewer Config](./config.md) +- 当创建完成Viewer时,默认拥有一个viewport,表示整个canvas + +![视口](../assets/basic-concepts/viewport.png) + +### 内部渲染流程 + +用户设置的 `Config` 会通过 Render Pipeline 影响绘制列表生成。最终生成的 `DrawcallList` 保存每次调用绘制命令所需的信息。每个 Drawcall 对应一次底层图形 API 指令调用,因此 Drawcall 数量通常与 CPU 开销正相关。 + +想直观观察这部分流程,可以安装 Chrome 插件 Spector 抓取帧。 + +![Aholo-Viewer 内部渲染流程](../assets/basic-concepts/render-flow.png) + +### 使用小结 + +一次基本渲染可以概括为: + +1. 构建 `Scene`。 +2. 通过 `add` 接口将物体和灯光加入场景树。 +3. 设置场景、摄像机和配置。 +4. 调用 `render` 接口触发渲染。 + +## 模型支持 + +理论上Aholo-Viewer支持任意能够转换到Aholo-Viewer结构的模型的渲染,不过Aholo-Viewer还提供了一些通用格式模型的加载器可以按需取用: + +- gltf-loader: 用于加载以gltf/glb格式描述的模型,不过由于Aholo-Viewer目前主要使用Phong进行渲染,基于PBR的gltf材质可能转换不完全 +- draco-loader: 用于加载以draco描述的几何。需要进一步转换为Aholo-Viewer可以识别的结构 + +## 插件系统 + +Aholo-Viewer还提供一部分插件用于补充一些额外的功能(数据监控,动画等): + +- Aholo-Viewer-animation: 为Aholo-Viewer提供额外的动画支持,推荐使用gltf-loader进行构造,目前支持骨骼动画和常规的属性插值变换 + +## 相关链接 + +- [WebGL Fundamentals](https://webglfundamentals.org/) +- [WebGL2 Fundamentals](https://webgl2fundamentals.org/) +- [WebGPU Fundamentals](https://webgpufundamentals.org/) diff --git a/website/src/content/manual/zh-CN/chunk-lod.md b/website/src/content/manual/zh-CN/chunk-lod.md new file mode 100644 index 0000000..2019c87 --- /dev/null +++ b/website/src/content/manual/zh-CN/chunk-lod.md @@ -0,0 +1,81 @@ +--- +title: Chunk Lod +description: Chunk Lod 说明 +order: 8 +--- + +## 背景 + +`3DGS`目前在渲染大规模场景的情况下,需要的系统资源远超目前常规设备硬件规格,导致大规模`3DGS`基本不可能在常规设备上渲染,因此需要采用`stream+lod`的形式通过放弃不重要部分的细节提升视觉中心的质量以达到以较少系统资源需求展示大规模场景的目标。`@manycore/aholo-viewer`采用了分块+lod的`chunk-lod`的实现作为主要`lod`实现。`lod`生成采用了不需要依赖训练的后处理融合高斯的实现。 + +![chunk-lod](../assets/chunk-lod/structure.png) + +## `chunk-lod`生成 + +`@manycore/aholo-viewer`使用的`chunk-lod`数据主要可以通过[`@manycore/aholo-splat-transform`](./splat-transform.md)生成。我们`chunk-lod`生成主要经过以下几个步骤: + +1. 块分割 + > 块分割主要采用八叉树进行分割,默认单块最大大小为`400000`高斯,对于大型方案可以增加到`800000`或者更多以控制最终产生的`chunk`数量 +2. 高斯查找和融合 + > 在每个块内,每级别的`lod`使用上一级别的结果生成,每个级别通过循环查找和合并使其达到目标数量。 + > + > 对于保留数量较少的级别,会进一步对高斯进行放大处理以减少级别保留数量过少导致的空洞。 + > + > 我们在融合后还会进行进一步的`opacity`剔除,减少可见性较差的高斯。 + > + > 同时当高斯数量少于一定数量时,我们会不再进行进一步的融合,虽然此时会导致输出数量略多于目标数量,但是整体偏差很小,但是能减少最终输出的块整体还是值得的。 + > + > 为了防止无法正常产出结果,我们会设定每个级别的最大迭代次数,当触及最大迭代次数时,即使未达到目标数量,我们依旧会直接跳出。 +3. 输出处理 + > 输出时,会针对低级别的数据进行整体打包,一般低级别数据为原始数据的`1%`,数据量很少,打包后也能减少没必要块。完整处理后我们会输出`lod-meta.json`作为`chunk-lod`的描述文件。 + +参考资料: + +- [A Hierarchical 3D Gaussian Representation for Real-Time Rendering of Very Large Datasets](https://repo-sam.inria.fr/fungraph/hierarchical-3d-gaussians/) +- [NanoGS: Training-Free Gaussian Splat Simplification](https://saliteta.github.io/NanoGS/) + +## `lod-meta.json`格式说明 + +```typescript +interface IBox { + min: [number, number, number]; + max: [number, number, number]; +} + +// typings for +interface LodMeta { + magicCode: 2500660; + type: 'lod-splat'; + version: string; + counts: number; + shDegree: number; + levels: number; + files: string[]; + forwardBox: IBox; + permanentFiles: number[]; + tree: Array<{ + bound: IBox; + lods: Array<{ + file: number; + offset: number; + count: number; + }>; + }>; +} +``` + +- `counts`: `level 0`总高斯数量 +- `shDegree`: 球协阶数 +- `forwardBox`: `level 0`所有高斯中约`80%`高斯球体所在的空间的包围盒。 +- `files`: 文件列表 +- `permanentFiles`: 需要内存/显存持久化的文件索引 +- `tree`: `lod`的分块树 + - `tree[i].bound`: 分块的包围盒,此包围盒同样采用近似的分布计算,排除离群点 + - `tree[i].lods`: 分块的lod数据 + - `tree[i].lods[j].file`: 数据文件索引 + - `tree[i].lods[j].start`: 起始高斯偏移 + - `tree[i].lods[j].count`: 高斯数量 + +## `Lod`调度器 + +`@manycore/aholo-viewer`提供完整的`chunk-lod`调度器,接口参考[`LodSplat`](api:SplatUtils.LodSplat),使用样例可以参考[splatting-lod-stream](../../examples/splatting-lod-stream/) diff --git a/website/src/content/manual/zh-CN/config.md b/website/src/content/manual/zh-CN/config.md new file mode 100644 index 0000000..e2756d7 --- /dev/null +++ b/website/src/content/manual/zh-CN/config.md @@ -0,0 +1,26 @@ +--- +title: Aholo-Viewer Config 配置 +description: 汇总 ViewerConfig、渲染模式和后期效果配置项。 +order: 3 +--- + +## 概览 + +`ViewerConfig` 控制 Aholo-Viewer 默认渲染行为 + +## 配置路径 + +```typescript +function setViewerConfig(ctx: Viewer | Viewport, config: IViewerConfig); +``` + +### [`PipelineConfig`](api:IPipelineConfig) + +pipelineConfig用于控制所有的管线功能,每个管线部分可以通过`enable`选项进行开关。 + +- [`Background`](api:IBackgroundPluginConfig): 用于控制背景(如天空盒)和地面网格的渲染行为。 +- [`Composite`](api:ICompositePluginConfig): 用于控制输出前合成,一般用于优化多视图渲染性能。 +- [`Splatting`](api:ISplattingPluginConfig): 用于控制`3DGS`渲染行为,具体参数说明可以参考[3dgs-preset-config](./3dgs-preset-config.md)。 +- [`TAA`](api:ITaaPluginConfig): 用于控制静态时域超采样抗锯齿的渲染行为。 + +[完整参数说明](api:IViewerConfig) diff --git a/website/src/content/manual/zh-CN/getting-started.md b/website/src/content/manual/zh-CN/getting-started.md new file mode 100644 index 0000000..9c48bee --- /dev/null +++ b/website/src/content/manual/zh-CN/getting-started.md @@ -0,0 +1,127 @@ +--- +title: 快速开始 +description: 快速开始 +order: 1 +--- + +## 使用方式(以Vite为例) + +### 安装相关依赖 + +```bash +npm install --save @manycore/aholo-viewer +npm install --save-dev vite typescript # 推荐使用typescript开发 +``` + +### 创建代码 + +- `index.html` + + ```html + + + + My first aholo viewer app + + + + + + ``` + +- `index.ts` + + ```javascript + import { + createViewer, + setViewerConfig, + PerspectiveCamera, + BackgroundMode, + Vector3, + Color, + SplatLoader, + SplatUtils, + } from '@manycore/aholo-viewer'; + + const SPLAT_URL = 'https://holo-cos.aholo3d.cn/aholo-opensource/gs_file/bear/bear.3d71a266.sog'; + // create container & append + const container = document.createElement('div'); + container.style.width = '500px'; + container.style.height = '500px'; + container.style.display = 'block'; + document.body.appendChild(container); + + async function createScene() { + const viewer = createViewer('example-viewer', container, {}); + const camera = new PerspectiveCamera(60, 1, 0.1, 2000); + + const resp = await fetch(SPLAT_URL); + const buffer = await resp.arrayBuffer(); + const data = await SplatLoader.parseSplatData( + SplatLoader.SplatFileType.SOG, + new Uint8Array(buffer), + SplatLoader.SplatPackType.Compressed, + ); + const splat = await SplatUtils.createSplat(data); + + // the splat is -Y UP, open cv coordinate + camera.up.set(0, -1, 0); + camera.position.set(-1.5, -0.5, 0); + camera.lookAt(new Vector3(0, 0, 0)); + + viewer.getScene().add(splat); + viewer.setCamera(camera); + setViewerConfig(viewer, { + pipeline: { + Background: { + background: { + active: BackgroundMode.BasicBackground, + basic: { + color: new Color(0, 0, 0), + }, + }, + ground: { + enabled: false, + }, + }, + Splatting: { + enabled: true, + }, + TAA: { + enabled: false, + }, + }, + }); + + function render() { + viewer.render(); + } + + // handle viewer.requestRender call + viewer.requestRenderHandler = function () { + requestAnimationFrame(render); + }; + + requestAnimationFrame(render); + } + + createScene(); + ``` + +### 启动应用 + +```bash +npx vite +``` + +看到如下输出后,在浏览器内打开链接即可: + +``` +VITE v8.0.14 ready in 83 ms + +➜ Local: http://localhost:5173/ +➜ Network: use --host to expose +➜ press h + enter to show help +``` + +![样例](../assets/getting-started/example.png) diff --git a/website/src/content/manual/zh-CN/physics-collision.md b/website/src/content/manual/zh-CN/physics-collision.md new file mode 100644 index 0000000..3bf9401 --- /dev/null +++ b/website/src/content/manual/zh-CN/physics-collision.md @@ -0,0 +1,90 @@ +--- +title: 物理碰撞 +description: 从 3DGS 场景生成体素或网格碰撞体,并做射线、胶囊体等实时碰撞查询。 +order: 6 +--- + +## 背景 + +3DGS 重建场景是高斯点云,没有实体边界,无法直接用于行走与碰撞。物理碰撞模块把重建空间转换为**可查询的碰撞数据**,用体素或网格描述地面、墙体、遮挡物和可通行区域,为行走模式、相机避障、区域限制和空间交互提供约束。 + +体素碰撞体由 `splat-transform` 的 `Voxel` 任务从 3DGS 资产生成。下文说明体素文件格式与运行时查询方式。也可额外提供 `collision.glb` 网格碰撞体。 + +## 概述 + +体素数据将场景占用编码为 **稀疏体素八叉树(SVO)**,用于运行时碰撞与射线检测,例如: + +- **Raycast**:拾取、贴地、视线检测 +- **球体 / 胶囊体**:角色穿透修正(depenetration) + +编码采用 **Laine–Karras** 紧凑布局,与 [playcanvas/splat-transform](https://github.com/playcanvas/splat-transform)、[playcanvas/supersplat-viewer](https://github.com/playcanvas/supersplat-viewer) 约定一致。 + +## 稀疏八叉树结构 + +八叉树在均匀体素网格上划分空间,只存储**非空**区域,以压缩大场景数据。 + +**层级** + +- 根到叶共 `treeDepth` 层;最细一层体素边长为 `voxelResolution`。 +- 每个叶节点覆盖 **4×4×4** 体素(`leafSize = 4`),共 64 个占用位。 + +**节点类型**(`nodes` 数组中每个 `uint32`) + +| 类型 | 含义 | +| ------------ | --------------------------------------------------------------------------------------------------------------------- | +| **内部节点** | 高 8 位为 `childMask`(8 个子八分体是否存在),低 24 位为第一个子节点在数组中的索引;其余子节点下标由 popcount 推算。 | +| **全实心叶** | 值为 `0xFF000000`(`SOLID_LEAF_MARKER`),表示该 4×4×4 块全部占用。 | +| **混合叶** | `childMask == 0`,低 24 位指向 `leafData` 中的 64 位掩码,按位标记块内实心体素。 | + +**`leafData`** + +- 每个混合叶占 2 个 `uint32`(`lo`、`hi`),共 64 bit。 +- 块内体素 `(vx, vy, vz)` 的位索引:`vx + vy * 4 + vz * 16`(各分量 ∈ [0, 3])。 + +**遍历** + +`nodes` 按广度优先紧凑存储,只保留 `childMask` 标记存在的子节点。查询时从根沿**一条路径**下潜 `treeDepth` 层,无需遍历整棵树。 + +**查询单个体素是否占用**:世界坐标 → 体素 `(ix, iy, iz)` → 叶块 `(⌊ix/4⌋, …)`。从 `nodes[0]` 向下: + +- **全实心叶**:判定为占用。 +- **混合叶**:用 `(ix&3, iy&3, iz&3)` 查 `leafData` 对应位。 +- **内部节点**:按当前层从叶块坐标取八分体编号;若无子节点则为空,否则下一节点下标为 `baseOffset` 加 popcount。 + +**射线遍历**:在网格包围盒内用 3D DDA 逐格前进,每格重复上述占用查询。 + +**输出文件** + +- `voxel-meta.json`:网格边界、体素尺寸、`treeDepth`、`nodeCount`、`leafDataCount` 等。 +- `voxel.bin`:连续二进制,`nodes` 在前、`leafData` 在后(均为 `uint32` 数组)。 +- 可选 `collision.glb` 网格碰撞体。 + +## Raycast 与碰撞查询 + +`splat-transform` 只负责**生成**体素数据;射线检测、穿透修正等由**运行时**加载八叉树后实现。常见流程如下。 + +**射线检测(Raycast)** + +1. 射线与网格包围盒求交,限定搜索范围; +2. 用 **3D DDA** 沿射线逐格前进:比较与各轴体素边界的参数 `t`,进入 `t` 最小的一格; +3. 每格沿八叉树下潜,查询是否占用; +4. 遇到第一个实心体素即为交点;走出包围盒仍未命中则视为无交。 + +射线方向不必归一化。常用于贴地、拾取、短距离障碍检测。 + +**位置占用** + +给定世界坐标,映射为体素索引后,按上文方式查询是否在物体内部。 + +**球体 / 胶囊体** + +在包围体内的体素格中,对每个实心体素的轴对齐方块,计算与球心或胶囊轴线的距离;小于半径则存在穿透,沿最短方向累加推出位移。多面接触时可迭代若干次。 + +## 致谢 + +体素管线、八叉树编码及文件格式主要参考 PlayCanvas 开源项目: + +| 项目 | 链接 | 说明 | +| --------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| **splat-transform** | https://github.com/playcanvas/splat-transform | 体素化、导航填充/雕刻、八叉树导出与碰撞网格生成 | +| **supersplat-viewer** | https://github.com/playcanvas/supersplat-viewer | 运行时体素碰撞(raycast、球/胶囊查询)参考实现;见 [Issues](https://github.com/playcanvas/supersplat-viewer/issues) | diff --git a/website/src/content/manual/zh-CN/playground.md b/website/src/content/manual/zh-CN/playground.md new file mode 100644 index 0000000..26c40f9 --- /dev/null +++ b/website/src/content/manual/zh-CN/playground.md @@ -0,0 +1,70 @@ +--- +title: Playground +description: Playground使用指南 +order: 6 +--- + +## 概览 + +[`Playground`](../../playground/)是一个方便用户直接使用`@manycore/aholo-viewer`进行代码验证的轻量在线编辑器。编辑器基于[`monaca-editor`](https://github.com/microsoft/monaco-editor)开发,并提供了完整的`typescript`支持和基础代码提示功能 + +![playground](../assets/playground/playground.zh-CN.jpeg) + +## 使用方式 + +基础代码模板: + +```typescript +import { type Viewer } from '@manycore/aholo-viewer'; +import type { RenderRuntime } from '../../client/render-runtime'; + +// typing of `RenderRuntime` +// interface RenderRuntime { +// // core renderer +// renderer: RuntimeRenderer; +// // camera interaction controller +// control: CameraControl; +// // loading fame controller +// loading: RuntimeLoadingController; +// // a config panel component base on tweakpane +// configPanel: RuntimeConfigPanel; +// // indexed db cache storage +// indexedDB: RuntimeIndexedDBStorage; +// // abort signal dispatcher +// signal: AbortSignal; +// } + +export default async function runner({ renderer, control, loading, configPanel, indexedDB, signal }: RenderRuntime) { + const const { scene, viewer } = renderer; + // do work with scene & viewer + // .... + // use `throwIfAborted(signal)` to check whether abort requested + // use `loading.show(info)` to update the loading to what ever you want to indicate which step is running + + // config frame call back, return a boolean to indicate whether anything updated + renderer.frame(({ delta }) => { + const cameraUpdated = control.update(delta); + let animationUpdated = false; + // update animation here... + return cameraUpdated || animationUpdated; + }); + + // request next frame render + renderer.render(); + // hide loading frame + loading.hide(); +} + +function throwIfAborted(signal: AbortSignal) { + if (signal.aborted) { + throw new DOMException('The splatting basic sample load was aborted.', 'AbortError'); + } +} +``` + +除了基础模板外,部分[示例](../../examples/)也可以通过示例页面的下方按钮或者`playground`下方的预设选择直接打开现有的样例代码直接查看编辑。完成编辑后,点击**运行**执行代码即可看到展示效果。已经完成编辑的代码也可以通过直接复制链接分享给其他人。 + +## 注意事项 + +- 目前`playground`不支持导入第三方库。 +- 如果需要反馈`@manycore/aholo-viewer`的相关问题,我们推荐在`playground`构造最小样例一并提交至[issues](https://github.com/manycoretech/aholo-viewer/issues) diff --git a/website/src/content/manual/zh-CN/splat-transform.md b/website/src/content/manual/zh-CN/splat-transform.md new file mode 100644 index 0000000..9e88106 --- /dev/null +++ b/website/src/content/manual/zh-CN/splat-transform.md @@ -0,0 +1,479 @@ +--- +title: splat-transform +description: splat-transform使用指南 +order: 5 +--- + +## 背景 + +`splat-transform`是面向 Aholo Viewer 的 3DGS 处理工具,用于格式转换、数据简化、LOD 生成和体素碰撞体生成。 + +## 环境需求 + +- Node.js >= 22.22.1 +- Windows: Windows 22H2+,x86_64,D3D12 或 Vulkan 兼容显卡。需要 GPU 功能时,建议使用独立显卡。 +- Linux: x86_64, ARM64,glibc >= 2.34,libstdc++ >= 3.4.30,Vulkan 兼容显卡。需要 GPU 功能时,建议使用独立显卡。 +- macOS: 仅苹果芯片,ARM64。 + +### 需要GPU的功能 + +- SOG 生成。 +- Voxel 生成,且 `backend` 设置为 `gpu`。 + +## 格式说明 + +### 输入格式 + +- `ply` +- `sog` +- `ksplat` +- `splat` +- `spz` +- `lcc` +- `esz` +- `compressed.ply`,即 supersplat 压缩 ply +- `meta.json`,即 sog 未打包 meta 数据 + +### 输出格式 + +- `ply` +- `spz` +- `uspz`,即未 gzip 的 spz +- `splat` +- `sog` +- `esz` + +### modify格式说明 + +```javascript +{ + isRowMatrix: boolean; // 确认行矩阵或列矩阵,默认为 true + transform: number[]; // model级别的变换 + deletedIndices: number[]; // 删除的下标,bitmap形式 + indicesTransform: Array<{ indices: number[]; transform: number[] }>; // 局部变换的列表 +} +``` + +## 使用方式 + +### 如何安装 + +```bash +npm install @manycore/aholo-splat-transform -g +``` + +### CLI 模式 + +```bash +splat-transform create # 转换3DGS格式 +splat-transform lod:auto --ratio # 简化3DGS到指定比例 ratio [0-1] +splat-transform lod:auto-chunk --type --max-chunk-counts # 生成可以被调度的多级别lod数据,--max-chunk-counts 最大分块大小 +``` + +### pipeline 模式(推荐) + +```bash +splat-transform pipeline.json +``` + +#### pipeline 描述文件(`pipeline.json`) + +```json +{ + "version": 1, + "tasks": [ + { + "id": "0", + "type": "Read", + "config": { "inputs": ["a.ply"], "output": "cache0" } + }, + { + "id": "1", + "type": "AutoChunkLod", + "config": { "input": "cache0", "output": "cache0", "type": "spz" } + }, + { + "id": "2", + "type": "Write", + "config": { "input": "cache0", "output": "a-lod" } + } + ] +} +``` + +#### Task + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
名称功能参数类型必须(默认值)说明
Read读取多个高斯文件并且合并成一个SplatData对象inputsstring[]Y输入的文件路径
outputstringY写入资源key
maxShDegreenumber
0..=3
N(3)最大球协阶数
Write将SplatData对象按指定格式存储到磁盘inputstringY读取资源key
outputstringY写出资源路径
compressLevelnumber
0..=9
N(6)gzip压缩level
enableMortonSortbooleanN(true)开启莫顿排序
Modify修改SplatData对象inputstringY读取资源key
outputstringY写入资源key
modifyPathsstring[]N([])modify json 文件路径
格式参考modify格式说明
AutoLod生成融合高斯结果inputstringY读取资源key
outputstringY写入资源key
countsnumberN(Infinity)最大保留数量
rationumber
0..=1
N(0.3)最大保留比例
AutoChunkLod生成分块融合高斯结果,使用需要结合lod调度模块inputstringY读取资源key
outputstringY写入资源key
typestringYchunk的文件类型,参考输出格式
forceSpzFormatThresholdnumberN(0)目前由于数量少的场景下sog的压缩率很差,所以增加了这个参数,低于这个参数的chunk会强制转换成spz格式,建议实际中设置 200000
maxChunkCountsnumberN(400000)chunk的最大高斯点数量
levels +
+                
+Array<{
+    precision: number,
+    scaleBoost: number
+}>
+                
+            
+
+ N +
+                
+[
+    { precision: 1.0, scaleBoost: 1 },
+    { precision: 0.5, scaleBoost: 1 },
+    { precision: 0.25, scaleBoost: 1 },
+    { precision: 0.05, scaleBoost: 1.01 },
+    { precision: 0.01, scaleBoost: 1.02 },
+]
+                
+            
+
chunk的最大高斯点数量
Voxel生成体素碰撞体inputstringY读取资源key
outputstringY输出文件路径
voxelResolutionnumberN(0.05)体素尺寸
opacityCutoffnumberN(0.1)体素筛选阈值,值越高体素越容易被剔除,漂浮物多的方案可以适当提高此值
backend + string +
    +
  • cpu
  • +
  • gpu
  • +
+
N(gpu)生成方式,默认使用gpu,可选cpu。cpu的耗时会明显高于gpu。两者的结果会有细微差异。
box +
+                
+{
+    minCorner: [number, number, number],
+    maxCorner: [number, number, number]
+}
+                
+            
+
+ N +
+                
+{
+    minCorner: [-100, -100, -100],
+    maxCorner: [100, 100, 100]
+}
+                
+            
+
场景box限制(离群点对体素化性能影响极大且其输出无意义,所以目前采用最简单的方案限制了体素的生成范围)
navCapsule +
+                
+{
+    height: number,
+    radius: number
+}
+                
+            
+
N(null) + 两者均服务于导航简化功能。navCapsule用于设置导航体高度和半径,navSeed设置导航起始中心。 + 开启后,会将体素按照导航体可达范围进行简化,能够优化体素(未完善,部分情况下可能存在副作用所以默认不开启) +
navSeed +
+                
+{
+    x: number,
+    y: number,
+    z: number
+}
+                
+            
+
N(null)
+ +#### 使用样例 + +- 把 a.ply 和 b.ply 应用修改以后转换成 c.spz + ```json + { + "version": 1, + "tasks": [ + { + "id": "0", + "type": "Read", + "config": { + "inputs": ["a.ply", "b.ply"], + "output": "cache0" + } + }, + { + "id": "1", + "type": "Modify", + "config": { + "input": "cache0", + "output": "cache0", + "modifyPaths": ["a.json", "b.json"] + } + }, + { + "id": "2", + "type": "Write", + "config": { + "input": "cache0", + "output": "c.spz" + } + } + ] + } + ``` +- 对 a.ply 应用修改以后生成 auto chunk lod + ```json + { + "version": 1, + "tasks": [ + { + "id": "0", + "type": "Read", + "config": { "inputs": ["a.ply"], "output": "cache0" } + }, + { + "id": "1", + "type": "Modify", + "config": { + "input": "cache0", + "output": "cache0", + "modifyPaths": ["a.json"] + } + }, + { + "id": "2", + "type": "AutoChunkLod", + "config": { + "input": "cache0", + "output": "cache0", + "type": "spz" + } + }, + { + "id": "3", + "type": "Write", + "config": { + "input": "cache0", + "output": "a-lod" + } + } + ] + } + ``` +- 生成体素碰撞体 + ```json + { + "version": 1, + "tasks": [ + { + "id": "0", + "type": "Read", + "config": { + "inputs": ["input.ply"], + "output": "cache0" + } + }, + { + "id": "1", + "type": "Voxel", + "config": { + "input": "cache0", + "output": "voxel-output", + "voxelResolution": 0.05, + "opacityCutoff": 0.1, + "navCapsule": { + "height": 1.4, + "radius": 0.2 + }, + "navSeed": { + "x": 0, + "y": 0, + "z": 0 + } + } + } + ] + } + ``` + +## 注意事项 + +- 当需要生成`SOG`或者使用`GPU`生成`Voxel`时,推荐使用性能较好的独立显卡。当转换比较大的数据到`SOG`时,需要10GB以上显存。 +- 当生成`chunk-lod`时(既使用`AutoChunkLod`或者`lod:auto-chunk`生成),推荐使用`spz`或者`esz`输出,在分块和多级`lod`后,会存在数据量很少数据量比较小的块,对于这些块`sog`压缩率表现不如`spz`,也可以配置`forceSpzFormatThreshold`来控制小于某个数量的块强制使用`spz` +- `chunk-lod`生成过程中对资源消耗比较大,推荐使用配置比较高的机器配置。对于大型数据推荐内存 >= 32GB, CPU核心数>=16(含超线程)。当无法直接生成时,可以先进行简单的块分割再生成,之后合并`lod-meta.json`,合并可以使用来自`@manycore/aholo-splat-dev-server@>=1.0.1`的`merge-lod`指令。 diff --git a/website/src/content/manual/zh-CN/viewer.md b/website/src/content/manual/zh-CN/viewer.md new file mode 100644 index 0000000..4af6a07 --- /dev/null +++ b/website/src/content/manual/zh-CN/viewer.md @@ -0,0 +1,74 @@ +--- +title: Viewer +description: Viewer使用指南 +order: 7 +--- + +## 概览 + +[`Viewer`](../../viewer/)是基于`@manycore/aholo-viewer`搭建的快速在线查看器,可以导入任意支持的资源直接查看展示效果,支持由[`@manycore/aholo-splat-transform`](./splat-transform.md)生成的lod格式数据 + +> 由于浏览器无法直接访问本地文件,对于`chunk-lod`的数据,需要使用服务器或者`CDN`承载。可以使用`@manycore/aholo-splat-dev-server`快速承载。 + +![viewer](../assets/viewer/viewer.zh-CN.jpeg) + +## 使用指南 + +- 左侧面板用于导入支持的格式数据和配置相机 + > 支持导入本地文件,url和剪贴板数据(url),如果使用本地数据,仅支持完整的单个数据,不支持`chunk-lod`形式的数据。 + > + > 相机提供可3种可切换的坐标系: OpenCV(-Y为上方向,点云坐标系),OpenGL(+Y为上方向,常规模型坐标系),Aholo(+Z为上方向,aholo平台数据默认坐标系)。 +- 右侧面板用于控制`3DGS`相关功能和管线配置,可以参考[`3dgs-preset-config`](./3dgs-preset-config.md) + +## 本地搭建快速验证平台 + +### 安装相关依赖 + +```bash +npm install @manycore/aholo-splat-transform -g +npm install @manycore/aholo-splat-dev-server -g +``` + +### 使用方式 + +`@manycore/aholo-splat-transform`完整使用指南可以参考[使用指南](./splat-transform.md),此处不再赘述。 + +### `@manycore/aholo-splat-dev-server` + +`@manycore/aholo-splat-dev-server`提供两个可执行命令,`splat-dev-server`是一个完整的本地快速承载服务,提供了快速承载相关资源, +`merge-lod`提供将多个`lod-meta.json`和相关资源合并成一个新的`lod-meta.json`,用于将分块处理的大型`3DGS`文件`chunk-lod`合并回一个。 + +- `splat-dev-server`: 用于启动快速承载`Viewer`使用的资源的服务器 + > ```bash + > splat-dev-server [options] + > Options: + > --help Show help [boolean] + > --version Show version number [boolean] + > -a, --address Address to listen [string] [default: "127.0.0.1"] + > -p, --port Port to listen [number] [default: 3000] + > ``` + > + > 启动后可看到如下输出 + > + > ```bash + > ======================================== + > Splat dev server started + > Host: 127.0.0.1:3000 + > Root: ./chunk-lod + > Base URL: http://127.0.0.1:3000 + > ======================================== + > ``` + > + > 通过`Base URL`访问`Root`下资源即可,可以直接填入`Viewer`对应的位置。 + > + > **注意:当在`Viewer`种使用时浏览器可能弹出权限要求,请允许。`Viewer`不会访问未被承载的资源,也不会收集任何用户信息。** +- `merge-lod`: 用于把多个`chunk-lod`的生成结果合并为一个,通常用于分块生成`chunk-lod`后合并为一个完整的大型`chunk-lod` + > ```bash + > merge-lod -i -o + > + > Options: + > --help Show help [boolean] + > --version Show version number [boolean] + > -i, --input Input lod meta files(lod-meta.json) [array] [required] + > -o, --output Output directory [string] [required] + > ``` diff --git a/website/src/env.d.ts b/website/src/env.d.ts new file mode 100644 index 0000000..d597684 --- /dev/null +++ b/website/src/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/website/src/i18n/dictionary.ts b/website/src/i18n/dictionary.ts new file mode 100644 index 0000000..5fbd25b --- /dev/null +++ b/website/src/i18n/dictionary.ts @@ -0,0 +1,112 @@ +import type { Locale } from './locales.js'; + +type NavKey = 'home' | 'playground' | 'viewer' | 'api' | 'examples' | 'manual'; + +interface Dictionary { + siteName: string; + siteDescription: string; + nav: Record; + common: { + run: string; + preset: string; + source: string; + }; + examples: { + title: string; + description: string; + }; + playground: { + title: string; + description: string; + editor: string; + preview: string; + inspector: string; + ready: string; + error: string; + fps: string; + drawCalls: string; + objects: string; + }; + viewer: { + title: string; + description: string; + }; +} + +export const dictionary: Record = { + 'zh-CN': { + siteName: 'Aholo Viewer', + siteDescription: '能力丰富的 3D Gaussian Splatting 高性能渲染引擎。', + nav: { + home: '首页', + playground: 'Playground', + viewer: 'Viewer', + api: 'API', + examples: '示例', + manual: '手册', + }, + common: { + run: '运行', + preset: '预设', + source: '源码', + }, + examples: { + title: '示例集合', + description: '浏览渲染器示例,打开详情页查看预览、源码,并继续在 Playground 中编辑。', + }, + playground: { + title: 'Playground', + description: '编辑示例代码,查看预览状态和基础渲染指标。', + editor: '编辑器', + preview: '渲染预览', + inspector: '检查器', + ready: '准备就绪', + error: '运行出错', + fps: 'FPS', + drawCalls: 'Draw calls', + objects: 'Objects', + }, + viewer: { + title: 'Viewer', + description: '导入 3DGS 文件,查看基础文件信息,并调整渲染参数。', + }, + }, + 'en-US': { + siteName: 'Aholo Viewer', + siteDescription: 'A feature-rich, high-performance 3D Gaussian Splatting rendering engine.', + nav: { + home: 'Home', + playground: 'Playground', + viewer: 'Viewer', + api: 'API', + examples: 'Examples', + manual: 'Manual', + }, + common: { + run: 'Run', + preset: 'Preset', + source: 'Source', + }, + examples: { + title: 'Example Collection', + description: + 'Browse renderer examples, open detail pages to view previews and source code, then continue editing in the Playground.', + }, + playground: { + title: 'Playground', + description: 'Edit example code, view preview status, and review basic rendering metrics.', + editor: 'Editor', + preview: 'Render Preview', + inspector: 'Inspector', + ready: 'Ready', + error: 'Runtime error', + fps: 'FPS', + drawCalls: 'Draw calls', + objects: 'Objects', + }, + viewer: { + title: 'Viewer', + description: 'Import 3DGS files, inspect basic file information, and tune renderer parameters.', + }, + }, +}; diff --git a/website/src/i18n/locales.ts b/website/src/i18n/locales.ts new file mode 100644 index 0000000..22abdf7 --- /dev/null +++ b/website/src/i18n/locales.ts @@ -0,0 +1,22 @@ +export const defaultLocale = 'zh-CN' as const; + +export const localeStorageKey = 'aholo:locale'; + +export const locales = [ + { + code: 'zh-CN', + label: '简体中文', + shortLabel: '中', + }, + { + code: 'en-US', + label: 'English', + shortLabel: 'EN', + }, +] as const; + +export type Locale = (typeof locales)[number]['code']; + +export function getLocaleMeta(locale: Locale) { + return locales.find(item => item.code === locale) ?? locales[0]; +} diff --git a/website/src/i18n/routes.ts b/website/src/i18n/routes.ts new file mode 100644 index 0000000..f7337d9 --- /dev/null +++ b/website/src/i18n/routes.ts @@ -0,0 +1,12 @@ +import type { Locale } from './locales.js'; + +export function localizedPath(locale: Locale, path = '/') { + const normalized = path.startsWith('/') ? path : `/${path}`; + return `/${locale}${normalized}`.replace(/\/{2,}/g, '/'); +} + +export function swapLocale(pathname: string, currentLocale: Locale, targetLocale: Locale) { + const prefix = `/${currentLocale}`; + const rest = pathname.startsWith(prefix) ? pathname.slice(prefix.length) || '/' : '/'; + return localizedPath(targetLocale, rest); +} diff --git a/website/src/layouts/BaseLayout.astro b/website/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..d675e6e --- /dev/null +++ b/website/src/layouts/BaseLayout.astro @@ -0,0 +1,130 @@ +--- +import Header from '../components/site/Header.astro'; +import { defaultLocale, type Locale } from '../i18n/locales.js'; +import { dictionary } from '../i18n/dictionary.js'; +import '../styles/theme.css'; +import '../styles/global.css'; +import '../styles/site.css'; + +interface Props { + lang?: Locale; + title?: string; + description?: string; + pageClass?: string; + workspaceFullscreen?: boolean; +} + +const lang = Astro.props.lang ?? defaultLocale; +const t = dictionary[lang]; +const rawTitle = Astro.props.title ?? t.siteName; +const title = rawTitle === t.siteName ? t.siteName : `${rawTitle} | ${t.siteName}`; +const description = Astro.props.description ?? t.siteDescription; +const pageClass = Astro.props.pageClass ?? ''; +const workspaceFullscreen = Astro.props.workspaceFullscreen ?? false; +const hasMobileNav = Astro.slots.has('mobileNav'); +const workspaceFullscreenScript = `(() => { + const params = new URLSearchParams(window.location.search); + const value = params.get('fullscreen'); + const normalized = value?.trim().toLowerCase(); + const fullscreenViewportTolerance = 12; + const browserChromeTolerance = 24; + const isExplicitlyWindowed = + normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off'; + const canInferBrowserFullscreenViewport = () => + !window.matchMedia('(max-width: 900px), (hover: none), (pointer: coarse)').matches; + const isBrowserFullscreenViewport = () => { + if (!canInferBrowserFullscreenViewport()) { + return false; + } + + const targetWidth = Math.max(window.screen.width, window.screen.availWidth); + const targetHeight = Math.max(window.screen.height, window.screen.availHeight); + + if (!targetWidth || !targetHeight || !window.outerWidth || !window.outerHeight) { + return false; + } + + const fillsScreen = + window.innerWidth >= targetWidth - fullscreenViewportTolerance && + window.innerHeight >= targetHeight - fullscreenViewportTolerance; + const browserChromeHidden = + Math.abs(window.outerWidth - window.innerWidth) <= browserChromeTolerance && + Math.abs(window.outerHeight - window.innerHeight) <= browserChromeTolerance; + + return fillsScreen && browserChromeHidden; + }; + const isFullscreen = + !isExplicitlyWindowed && + (normalized === '' || + normalized === '1' || + normalized === 'true' || + normalized === 'yes' || + normalized === 'on' || + params.get('view')?.trim().toLowerCase() === 'fullscreen' || + isBrowserFullscreenViewport()); + + if (isFullscreen) { + document.documentElement.dataset.workspaceFullscreen = 'true'; + } +})();`; +--- + + + + + + + + + + + + {title} + + + + {workspaceFullscreen && diff --git a/website/src/pages/[lang]/api/[...slug].astro b/website/src/pages/[lang]/api/[...slug].astro new file mode 100644 index 0000000..a5c0716 --- /dev/null +++ b/website/src/pages/[lang]/api/[...slug].astro @@ -0,0 +1,64 @@ +--- +import DocPager from '../../../components/docs/DocPager.astro'; +import DocsLayout from '../../../layouts/DocsLayout.astro'; +import { dictionary } from '../../../i18n/dictionary.js'; +import { locales, type Locale } from '../../../i18n/locales.js'; +import { localizedPath } from '../../../i18n/routes.js'; +import { getApiEntries, getApiNeighbors, type ApiEntry } from '../../../utils/api.js'; +import { apiSidebarItems } from '../../../utils/navigation.js'; + +export function getStaticPaths() { + return locales.flatMap(locale => + getApiEntries(locale.code).map(entry => ({ + params: { lang: locale.code, slug: entry.slug }, + props: { lang: locale.code, entry }, + })), + ); +} + +interface Props { + lang: Locale; + entry: ApiEntry; +} + +const { lang, entry } = Astro.props; +const t = dictionary[lang]; +const neighbors = getApiNeighbors(lang, entry.slug); +const headings = entry.headings; +--- + + +
+ { + entry.namespaceLabel === entry.categoryLabel + ? entry.categoryLabel + : `${entry.categoryLabel} / ${entry.namespaceLabel}` + } + {entry.kindLabel} +
+ +
+ + + diff --git a/website/src/pages/[lang]/api/index.astro b/website/src/pages/[lang]/api/index.astro new file mode 100644 index 0000000..c32310c --- /dev/null +++ b/website/src/pages/[lang]/api/index.astro @@ -0,0 +1,62 @@ +--- +import DocsLayout from '../../../layouts/DocsLayout.astro'; +import { dictionary } from '../../../i18n/dictionary.js'; +import { locales, type Locale } from '../../../i18n/locales.js'; +import { localizedPath } from '../../../i18n/routes.js'; +import { getApiEntryGroups } from '../../../utils/api.js'; +import { apiSidebarItems } from '../../../utils/navigation.js'; + +export function getStaticPaths() { + return locales.map(locale => ({ + params: { lang: locale.code }, + props: { lang: locale.code }, + })); +} + +interface Props { + lang: Locale; +} + +const { lang } = Astro.props; +const t = dictionary[lang]; +const groups = getApiEntryGroups(lang); +--- + + + + diff --git a/website/src/pages/[lang]/examples/[slug].astro b/website/src/pages/[lang]/examples/[slug].astro new file mode 100644 index 0000000..f59b294 --- /dev/null +++ b/website/src/pages/[lang]/examples/[slug].astro @@ -0,0 +1,133 @@ +--- +import ExampleRail from '../../../components/ExampleRail.astro'; +import ExamplePreview from '../../../components/ExamplePreview.astro'; +import BaseLayout from '../../../layouts/BaseLayout.astro'; +import { dictionary } from '../../../i18n/dictionary.js'; +import { locales, type Locale } from '../../../i18n/locales.js'; +import { localizedPath } from '../../../i18n/routes.js'; +import { examples, type ExampleItem } from '../../../utils/examples.js'; +import '../../../styles/examples.css'; + +export function getStaticPaths() { + return locales.flatMap(locale => + examples.map(example => ({ + params: { lang: locale.code, slug: example.slug }, + props: { lang: locale.code, example }, + })), + ); +} + +interface Props { + lang: Locale; + example: ExampleItem; +} + +const { lang, example } = Astro.props; +const t = dictionary[lang]; +const showPlaygroundEdit = example.surfaces.includes('playground'); +const playgroundHref = `${localizedPath(lang, '/playground/')}?example=${example.slug}`; +const editLabel = lang === 'zh-CN' ? '编辑' : 'Edit'; +const resizeLabel = lang === 'zh-CN' ? '调整示例栏和预览宽度' : 'Resize examples and preview'; +const expandLabel = lang === 'zh-CN' ? '展开示例栏' : 'Expand examples'; +const configLabel = lang === 'zh-CN' ? '配置' : 'Config'; +const tagsLabel = lang === 'zh-CN' ? '标签' : 'Tags'; +const previewLoadingLabel = lang === 'zh-CN' ? '正在加载示例' : 'Loading example'; +const previewErrorLabel = lang === 'zh-CN' ? '示例运行失败' : 'Example failed'; +--- + + + + + + + + +
+ + + + +
+ + { + showPlaygroundEdit && ( + + + + ) + } +
+
+ + +
diff --git a/website/src/pages/[lang]/examples/index.astro b/website/src/pages/[lang]/examples/index.astro new file mode 100644 index 0000000..7c9d834 --- /dev/null +++ b/website/src/pages/[lang]/examples/index.astro @@ -0,0 +1,35 @@ +--- +import { locales, type Locale } from '../../../i18n/locales.js'; +import { localizedPath } from '../../../i18n/routes.js'; +import { defaultExample } from '../../../utils/examples.js'; + +export function getStaticPaths() { + return locales.map(locale => ({ + params: { lang: locale.code }, + props: { lang: locale.code }, + })); +} + +interface Props { + lang: Locale; +} + +const { lang } = Astro.props; +const destination = localizedPath(lang, `/examples/${defaultExample.slug}/`); +--- + + + + + + + + Aholo Viewer Examples + + + + Open example + + diff --git a/website/src/pages/[lang]/index.astro b/website/src/pages/[lang]/index.astro new file mode 100644 index 0000000..fdd91b8 --- /dev/null +++ b/website/src/pages/[lang]/index.astro @@ -0,0 +1,281 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import InteractionGuide from '../../components/InteractionGuide.astro'; +import { dictionary } from '../../i18n/dictionary.js'; +import { locales, type Locale } from '../../i18n/locales.js'; +import { localizedPath } from '../../i18n/routes.js'; +import '../../styles/home.css'; + +export function getStaticPaths() { + return locales.map(locale => ({ + params: { lang: locale.code }, + props: { lang: locale.code }, + })); +} + +interface Props { + lang: Locale; +} + +const { lang } = Astro.props; +const t = dictionary[lang]; +const heroDescription = + lang === 'zh-CN' + ? '能力丰富的 3D Gaussian Splatting 高性能渲染引擎。' + : 'A feature-rich, high-performance 3D Gaussian Splatting rendering engine.'; + +const heroCards = [ + { + eyebrow: lang === 'zh-CN' ? 'LOD · 流式' : 'LOD · Streaming', + title: lang === 'zh-CN' ? '10亿级高斯场景' : 'Billion-Scale Gaussian Scenes', + description: + lang === 'zh-CN' + ? '利用 LOD 与流式加载,控制内存和显存在大场景下占用。' + : 'Use LOD and streaming to control memory and GPU memory usage in large scenes.', + }, + { + eyebrow: lang === 'zh-CN' ? 'Preset · Config' : 'Preset · Config', + title: lang === 'zh-CN' ? '多档位渲染配置' : 'Tiered Rendering Presets', + description: + lang === 'zh-CN' + ? '按需平衡性能与效果,满足不同情景下规模和画质目标。' + : 'Balance performance and visual quality on demand to meet scale and image-quality goals across different scenarios.', + }, + { + eyebrow: lang === 'zh-CN' ? '开源 · MIT' : 'Open Source · MIT', + title: lang === 'zh-CN' ? '开源 MIT' : 'MIT Open Source', + description: + lang === 'zh-CN' + ? '适合 SDK 接入、示例验证和二次开发。' + : 'Ready for SDK integration, example validation, and product extensions.', + }, +]; + +const features = [ + { + title: lang === 'zh-CN' ? '3D Gaussian Splatting 高性能渲染引擎' : 'High-Performance 3D Gaussian Splatting Engine', + subtitle: lang === 'zh-CN' ? '按目标平衡性能和效果' : 'Balance performance and visual quality by target', + body: + lang === 'zh-CN' + ? '通过细粒度参数设置,选择从效果优先到极限性能不同路径,简化渲染链路并减少内存和显存占用。' + : 'Use granular parameters to choose paths from quality-first to extreme performance, simplify the render pipeline, and reduce memory and GPU memory usage.', + points: + lang === 'zh-CN' + ? [ + '针对 GPU 特性优化,提供更优的加载、调度和渲染性能', + '支持 ply / spz / sog / splat / lcc / ksplat 等主流格式', + '覆盖效果优先、性能优先和极限性能', + ] + : [ + 'GPU-aware rendering optimizations improve loading, scheduling, and rendering performance', + 'Supports common formats including ply, spz, sog, splat, lcc, and ksplat', + 'Covers quality-first, performance-first, and extreme-performance profiles', + ], + primaryHref: localizedPath(lang, '/manual/3dgs-preset-config/'), + interactionHref: localizedPath(lang, '/examples/splatting-basic/'), + mediaSrc: 'https://holo-cos.aholo3d.cn/aholo-opensource/page/images/home-engine-feature.4e2c1275.avif', + }, + { + title: lang === 'zh-CN' ? '大场景 LOD 流式加载' : 'Large-Scene LOD Streaming', + subtitle: lang === 'zh-CN' ? '10 亿级高斯核,秒级加载' : 'Billion-Scale Gaussians, Loaded in Seconds', + body: + lang === 'zh-CN' + ? '通过 chunk-level LoD 与流式调度,让 10 亿高斯点级别的真实场景能在浏览器里被流畅加载和漫游。' + : 'Use chunk-level LoD and streaming scheduling to smoothly load and navigate real-world scenes with billion-scale Gaussian points in the browser.', + points: + lang === 'zh-CN' + ? ['chunk-level LoD 调度,按视锥优先级加载', '流式分批拉取,首屏 10 秒内进入', '数亿高斯点级别真实空间流畅承载'] + : [ + 'Chunk-level LoD scheduling loads by frustum priority', + 'Streamed batch fetching makes the first view available within 10 seconds', + 'Smoothly supports real spaces with hundreds of millions of Gaussian points', + ], + primaryHref: localizedPath(lang, '/api/splat-utils/classes/lod-splat/'), + interactionHref: localizedPath(lang, '/examples/splatting-lod-stream/'), + mediaSrc: 'https://holo-cos.aholo3d.cn/aholo-opensource/page/images/home-lod-feature.fa68afe7.avif', + }, + { + title: lang === 'zh-CN' ? '物理碰撞' : 'Physics Collision', + subtitle: + lang === 'zh-CN' ? '从重建资产生成可查询碰撞体' : 'Queryable colliders generated from reconstructed assets', + body: + lang === 'zh-CN' + ? '把 3DGS 重建空间转换为可查询的碰撞边界,为行走模式、相机避障、区域限制和空间交互提供稳定约束。' + : 'Turn 3DGS reconstructed spaces into queryable collision boundaries, providing stable constraints for walk mode, camera obstacle avoidance, area limits, and spatial interaction.', + points: + lang === 'zh-CN' + ? [ + '由 3DGS 场景资产生成体素碰撞体,也可接入网格碰撞数据', + '支持射线、胶囊体、地面检测和墙体阻挡等实时碰撞查询', + '行走模式、第三人称相机避障、区域限制等交互可复用碰撞数据', + ] + : [ + 'Generate voxel colliders from 3DGS scene assets, with mesh collision data as an alternate input', + 'Realtime ray, capsule, ground, and wall collision queries', + 'Walk mode, third-person camera obstacle avoidance, area limits, and other interactions can share the same collision data', + ], + primaryHref: localizedPath(lang, '/manual/physics-collision/'), + interactionHref: localizedPath(lang, '/examples/walk-demo/'), + mediaSrc: 'https://holo-cos.aholo3d.cn/aholo-opensource/page/images/home-walk-feature.59bf9c9e.avif', + }, + { + title: lang === 'zh-CN' ? '云端渲染' : 'Cloud Rendering', + subtitle: + lang === 'zh-CN' + ? '3DGS 与高保真网格,同屏实时呈现' + : '3DGS and high-fidelity meshes, rendered together in real time', + body: + lang === 'zh-CN' + ? '基于 OpenUSD 将 3DGS 与高保真 Mesh 置于同一场景,在云端实时混合渲染并串流输出。3DGS 负责实景氛围与细节,Mesh 负责结构与硬装精度——一帧合成,所见即所得。' + : 'With OpenUSD, place 3DGS and high-fidelity meshes in the same scene, render them together in real time in the cloud, and stream the result. 3DGS carries real-world atmosphere and detail, while meshes provide structural and hard-surface precision: one-frame composition, what you see is what you get.', + points: + lang === 'zh-CN' + ? ['3DGS与高保真网格同场景共存、同帧混合渲染', '云端实时渲染,低配终端也能流畅预览'] + : [ + '3DGS and high-fidelity meshes coexist in one scene and render together in the same frame', + 'Cloud realtime rendering keeps low-spec devices smooth', + ], + primaryHref: 'https://labs.aholo3d.cn/api-docs/api-reference#tag/rendercloud', + interactionHref: `https://www.kujiale.com/pub/tool/render/cloud-api-demo?lang=${lang === 'zh-CN' ? 'zh_CN' : 'en_US'}`, + mediaSrc: 'https://holo-cos.aholo3d.cn/aholo-opensource/page/images/feature-cloud.ae86e6af.avif', + }, + { + title: lang === 'zh-CN' ? '完整工具链支持' : 'Complete Toolchain Support', + subtitle: + lang === 'zh-CN' + ? '覆盖转换、LOD、编辑、拾取和生成体素碰撞体' + : 'Conversion, LOD, editing, picking, and voxel colliders', + body: + lang === 'zh-CN' + ? '提供多格式、LOD 和体素碰撞体生成以及编辑、拾取等必要能力。' + : 'Provides multi-format, LOD, and voxel collider generation plus essential editing and picking capabilities.', + points: + lang === 'zh-CN' + ? ['生成高质量 LOD 与流式资源', '支持编辑、拾取和体素碰撞体生成', '格式覆盖 ply / spz / sog / splat / lcc 等'] + : [ + 'High-quality LOD and streaming resource generation', + 'Editing, picking, and voxel collider generation', + 'Format coverage for ply, spz, sog, splat, lcc, and related inputs', + ], + primaryHref: localizedPath(lang, '/manual/splat-transform/'), + interactionHref: localizedPath(lang, '/examples/splatting-lod-stream/'), + mediaSrc: '/home/feature-tools.svg', + }, +]; +--- + + +
+
+ +
+
+

Aholo Viewer

+ {heroDescription} + +
+ { + heroCards.map(card => ( +
+ {card.eyebrow} +

{card.title}

+

{card.description}

+
+ )) + } +
+
+
+ + + +
+ +
+ { + features.map(feature => ( + + )) + } +
+
+
+ + diff --git a/website/src/pages/[lang]/manual/[...slug].astro b/website/src/pages/[lang]/manual/[...slug].astro new file mode 100644 index 0000000..c5ce2e4 --- /dev/null +++ b/website/src/pages/[lang]/manual/[...slug].astro @@ -0,0 +1,67 @@ +--- +import DocPager from '../../../components/docs/DocPager.astro'; +import DocsLayout from '../../../layouts/DocsLayout.astro'; +import { dictionary } from '../../../i18n/dictionary.js'; +import { locales, type Locale } from '../../../i18n/locales.js'; +import { localizedPath } from '../../../i18n/routes.js'; +import { getManualEntries, getManualEntry, getManualNeighbors } from '../../../utils/manual.js'; +import { manualSidebarItems } from '../../../utils/navigation.js'; + +export async function getStaticPaths() { + const paths = []; + + for (const locale of locales) { + const entries = await getManualEntries(locale.code); + + paths.push( + ...entries.map(page => ({ + params: { lang: locale.code, slug: page.slug }, + props: { lang: locale.code, slug: page.slug }, + })), + ); + } + + return paths; +} + +interface Props { + lang: Locale; + slug: string; +} + +const { lang, slug } = Astro.props; +const page = await getManualEntry(lang, slug); + +if (!page) { + throw new Error(`Manual page not found: ${lang}/${slug}`); +} + +const t = dictionary[lang]; +const neighbors = await getManualNeighbors(lang, page.slug); +const sidebarItems = await manualSidebarItems(lang); +const headings = page.headings; +--- + + +
+ + + diff --git a/website/src/pages/[lang]/manual/index.astro b/website/src/pages/[lang]/manual/index.astro new file mode 100644 index 0000000..2527543 --- /dev/null +++ b/website/src/pages/[lang]/manual/index.astro @@ -0,0 +1,47 @@ +--- +import DocsLayout from '../../../layouts/DocsLayout.astro'; +import { dictionary } from '../../../i18n/dictionary.js'; +import { locales, type Locale } from '../../../i18n/locales.js'; +import { localizedPath } from '../../../i18n/routes.js'; +import { manualSidebarItems } from '../../../utils/navigation.js'; +import { getManualEntries } from '../../../utils/manual.js'; + +export function getStaticPaths() { + return locales.map(locale => ({ + params: { lang: locale.code }, + props: { lang: locale.code }, + })); +} + +interface Props { + lang: Locale; +} + +const { lang } = Astro.props; +const t = dictionary[lang]; +const pages = await getManualEntries(lang); +const sidebarItems = await manualSidebarItems(lang); +--- + + + + diff --git a/website/src/pages/[lang]/playground.astro b/website/src/pages/[lang]/playground.astro new file mode 100644 index 0000000..5d36ec0 --- /dev/null +++ b/website/src/pages/[lang]/playground.astro @@ -0,0 +1,31 @@ +--- +import PlaygroundShell from '../../components/PlaygroundShell.astro'; +import BaseLayout from '../../layouts/BaseLayout.astro'; +import { dictionary } from '../../i18n/dictionary.js'; +import { locales, type Locale } from '../../i18n/locales.js'; +import '../../styles/playground.css'; + +export function getStaticPaths() { + return locales.map(locale => ({ + params: { lang: locale.code }, + props: { lang: locale.code }, + })); +} + +interface Props { + lang: Locale; +} + +const { lang } = Astro.props; +const t = dictionary[lang]; +--- + + + + diff --git a/website/src/pages/[lang]/viewer.astro b/website/src/pages/[lang]/viewer.astro new file mode 100644 index 0000000..d965d25 --- /dev/null +++ b/website/src/pages/[lang]/viewer.astro @@ -0,0 +1,31 @@ +--- +import ViewerShell from '../../components/ViewerShell.astro'; +import BaseLayout from '../../layouts/BaseLayout.astro'; +import { dictionary } from '../../i18n/dictionary.js'; +import { locales, type Locale } from '../../i18n/locales.js'; +import '../../styles/viewer.css'; + +export function getStaticPaths() { + return locales.map(locale => ({ + params: { lang: locale.code }, + props: { lang: locale.code }, + })); +} + +interface Props { + lang: Locale; +} + +const { lang } = Astro.props; +const t = dictionary[lang]; +--- + + + + diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro new file mode 100644 index 0000000..78de706 --- /dev/null +++ b/website/src/pages/index.astro @@ -0,0 +1,78 @@ +--- +import { defaultLocale, localeStorageKey, locales } from '../i18n/locales.js'; + +const fallbackDestination = `/${defaultLocale}/`; +const availableLocales = locales.map(locale => locale.code); +--- + + + + + + + + Aholo Viewer + + + + + + + + + + Go to Aholo Viewer + + diff --git a/website/src/styles/docs.css b/website/src/styles/docs.css new file mode 100644 index 0000000..201ba53 --- /dev/null +++ b/website/src/styles/docs.css @@ -0,0 +1,1372 @@ +.docs-shell { + display: grid; + grid-template-columns: minmax(230px, 280px) minmax(0, 1fr); + gap: 32px; + width: min(100%, 1380px); + max-width: calc(100vw - 32px); + margin: 0 auto; + padding: 42px 16px 70px; +} + +.docs-shell.has-toc { + grid-template-columns: minmax(230px, 280px) minmax(0, 1fr) minmax(170px, 220px); + gap: 28px; + width: min(100%, 1540px); + max-width: calc(100vw - 32px); +} + +.docs-sidebar { + position: sticky; + top: 80px; + align-self: start; + max-height: calc(100vh - 104px); + min-width: 0; + overflow-x: hidden; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--surface) 96%, transparent); + box-shadow: var(--shadow-soft); + scrollbar-color: color-mix(in srgb, var(--text-muted) 36%, transparent) transparent; + scrollbar-gutter: stable; + scrollbar-width: thin; +} + +.docs-sidebar::-webkit-scrollbar, +.doc-toc::-webkit-scrollbar { + width: 9px; +} + +.docs-sidebar::-webkit-scrollbar-track, +.doc-toc::-webkit-scrollbar-track { + background: transparent; +} + +.docs-sidebar::-webkit-scrollbar-thumb, +.doc-toc::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--text-muted) 34%, transparent); + background-clip: padding-box; +} + +.docs-sidebar::-webkit-scrollbar-thumb:hover, +.doc-toc::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--text-muted) 52%, transparent); + background-clip: padding-box; +} + +.docs-sidebar [hidden] { + display: none !important; +} + +.docs-sidebar-search { + position: sticky; + top: 0; + z-index: 1; + display: grid; + border-bottom: 1px solid var(--border-subtle); + background: var(--surface); +} + +.docs-sidebar-search-control { + position: relative; + min-width: 0; + height: 58px; +} + +.docs-sidebar-search-icon { + position: absolute; + top: 50%; + left: 18px; + width: 15px; + height: 15px; + color: var(--text-muted); + pointer-events: none; + transform: translateY(-50%); +} + +.docs-sidebar-search-icon::before, +.docs-sidebar-search-icon::after { + position: absolute; + content: ''; +} + +.docs-sidebar-search-icon::before { + top: 1px; + left: 1px; + width: 8px; + height: 8px; + border: 1.7px solid currentColor; + border-radius: 50%; +} + +.docs-sidebar-search-icon::after { + top: 10px; + left: 10px; + width: 6px; + height: 1.7px; + border-radius: var(--radius-pill); + background: currentColor; + transform: rotate(45deg); + transform-origin: left center; +} + +.docs-sidebar-search input { + width: 100%; + height: 100%; + min-width: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--text); + font: inherit; + line-height: 1.2; + padding: 0 14px 0 44px; + outline: none; + transition: + background 160ms ease, + box-shadow 160ms ease; +} + +.docs-sidebar-search input:focus-visible { + background: color-mix(in srgb, var(--surface-muted) 44%, transparent); + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--primary) 62%, transparent); +} + +.docs-sidebar-empty { + margin: 0; + color: var(--text-muted); + font-size: 0.86rem; + line-height: 1.45; + padding: 0 16px 12px 44px; +} + +.docs-sidebar nav { + display: grid; + padding: 8px; +} + +.docs-sidebar-group { + display: grid; + gap: 4px; + min-width: 0; + padding: 8px 0 6px; +} + +.docs-sidebar-group + .docs-sidebar-group { + border-top: 1px solid var(--border-subtle); +} + +.docs-sidebar-group > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + cursor: pointer; + list-style: none; + padding: 2px 10px 4px; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 820; + letter-spacing: 0.08em; + line-height: 1.2; + text-transform: uppercase; +} + +.docs-sidebar-group > summary::-webkit-details-marker { + display: none; +} + +.docs-sidebar-group > summary::after { + content: ''; + width: 7px; + height: 7px; + flex: 0 0 auto; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg) translateY(-2px); + transition: transform 160ms ease; +} + +.docs-sidebar-group[open] > summary::after, +.typedoc-html .tsd-member-group[open] > summary::after { + transform: rotate(225deg) translateY(-1px); +} + +.docs-sidebar-group > summary:hover { + color: var(--text); +} + +.docs-sidebar-group > summary span { + min-width: 0; + overflow-wrap: anywhere; +} + +.docs-sidebar-group > div { + display: grid; + gap: 2px; + padding-top: 4px; +} + +.docs-sidebar a { + display: grid; + gap: 4px; + min-width: 0; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--text-soft); + padding: 10px; + transition: + background 160ms ease, + border-color 160ms ease, + color 160ms ease; +} + +.docs-sidebar span, +.docs-sidebar small, +.doc-toc a { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +.docs-sidebar a:hover, +.docs-sidebar a.is-active { + border-color: var(--border-subtle); + background: color-mix(in srgb, var(--surface-muted) 82%, transparent); + color: var(--text); +} + +.docs-sidebar a.is-active { + border-color: color-mix(in srgb, var(--primary) 32%, var(--border)); + box-shadow: inset 3px 0 0 var(--primary); +} + +.docs-sidebar small { + color: var(--text-muted); + line-height: 1.45; +} + +.docs-content { + min-width: 0; + padding: 4px 0 0; +} + +.breadcrumbs { + display: flex; + flex-wrap: wrap; + gap: 8px; + color: var(--text-muted); + font-size: 0.88rem; + margin-bottom: 18px; +} + +.breadcrumbs a { + color: var(--text-muted); +} + +.breadcrumbs a:hover { + color: var(--primary-strong); +} + +.docs-heading { + padding-bottom: 24px; + border-bottom: 1px solid var(--border-subtle); +} + +.docs-heading h1 { + margin: 0; + color: var(--text); + font-size: 3rem; + font-weight: 740; + line-height: 1.06; + letter-spacing: 0; +} + +.docs-heading p { + max-width: 900px; + margin: 14px 0 0; + color: var(--text-muted); + font-size: 1.04rem; + line-height: 1.76; +} + +.markdown-body { + max-width: 800px; + padding-top: 8px; +} + +.typedoc-html { + display: grid; + gap: 20px; + max-width: 980px; + min-width: 0; + padding-top: 18px; + overflow-wrap: break-word; +} + +.doc-card-list, +.api-symbol-list { + display: grid; + gap: 12px; + padding-top: 24px; +} + +.api-group-list { + display: grid; + gap: 38px; + padding-top: 26px; +} + +.api-group { + min-width: 0; +} + +.api-group h2 { + min-width: 0; + margin: 0; + color: var(--text); + font-size: 1.12rem; + font-weight: 780; + overflow-wrap: anywhere; +} + +.doc-card-list a, +.api-symbol-list a { + display: grid; + gap: 8px; + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow-soft); + padding: 17px 18px; + transition: + border-color 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; +} + +.doc-card-list a:hover, +.api-symbol-list a:hover { + border-color: color-mix(in srgb, var(--primary) 42%, var(--border)); + box-shadow: var(--shadow); + transform: translateY(-2px); +} + +.doc-card-list strong, +.api-symbol-list strong { + min-width: 0; + color: var(--text); + font-size: 1rem; + overflow-wrap: anywhere; +} + +.doc-card-list p, +.api-symbol-list p { + min-width: 0; + margin: 0; + color: var(--text-muted); + line-height: 1.62; + overflow-wrap: anywhere; +} + +.doc-card-list span { + color: var(--code-type); + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 820; +} + +.api-symbol-list span { + width: fit-content; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-pill); + background: var(--surface-muted); + color: var(--text-muted); + font-size: 0.76rem; + font-weight: 720; + padding: 4px 8px; +} + +.api-symbol-list code { + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + overflow-x: auto; + color: var(--code-type); + font-size: 0.9rem; +} + +.api-page-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 20px 0 8px; + color: var(--text-muted); +} + +.api-page-meta span { + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--surface-muted); + padding: 5px 9px; + font-size: 0.8rem; + font-weight: 650; +} + +.doc-pager { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 34px; + padding-top: 24px; + border-top: 1px solid var(--border-subtle); +} + +.doc-pager div:last-child { + text-align: right; +} + +.doc-pager a { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--primary-strong); + font-weight: 760; +} + +.doc-toc { + position: sticky; + top: 80px; + align-self: start; + max-height: calc(100vh - 104px); + min-width: 0; + overflow-x: hidden; + overflow-y: auto; + border-left: 1px solid var(--border-subtle); + padding-left: 16px; + color: var(--text-muted); + scrollbar-color: color-mix(in srgb, var(--text-muted) 36%, transparent) transparent; + scrollbar-gutter: stable; + scrollbar-width: thin; +} + +.doc-toc strong { + display: block; + margin-bottom: 12px; + color: var(--text); + font-size: 0.76rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.doc-toc nav { + display: grid; + gap: 7px; +} + +.doc-toc a { + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 0.88rem; + line-height: 1.38; + padding: 3px 6px 3px 0; +} + +.doc-toc a:hover { + color: var(--primary-strong); +} + +.doc-toc a.is-nested { + padding-left: 12px; + font-size: 0.84rem; +} + +.markdown-body :where(h2, h3) { + scroll-margin-top: 96px; +} + +.markdown-body h2 { + margin: 38px 0 12px; + padding-top: 16px; + border-top: 1px solid var(--border-subtle); + color: var(--text); + font-size: 1.42rem; + line-height: 1.25; +} + +.markdown-body h2:first-child { + border-top: 0; + margin-top: 20px; +} + +.markdown-body h3 { + margin: 28px 0 10px; + color: var(--text); + font-size: 1.12rem; + line-height: 1.3; +} + +.markdown-body p, +.markdown-body li { + color: var(--text-muted); + font-size: 1rem; + line-height: 1.78; +} + +.markdown-body p { + margin: 0 0 16px; +} + +.markdown-body a { + color: var(--primary-strong); + font-weight: 720; + text-decoration-line: underline; + text-decoration-color: color-mix(in srgb, var(--primary) 68%, transparent); + text-decoration-thickness: 1.5px; + text-underline-offset: 0.22em; + transition: + color 160ms ease, + text-decoration-color 160ms ease; +} + +.markdown-body a:hover { + color: var(--accent); + text-decoration-color: currentColor; +} + +.markdown-body p > a:only-child { + text-decoration-thickness: 1.8px; +} + +.markdown-body p:has(> img:only-child) { + margin: 22px 0 28px; +} + +.markdown-body img { + display: block; + width: auto; + max-width: 100%; + max-height: min(68vh, 720px); + height: auto; + margin: 0 auto; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--surface); + box-shadow: var(--shadow-soft); + object-fit: contain; +} + +.markdown-body table { + display: block; + width: 100%; + max-width: 100%; + margin: 20px 0 28px; + overflow-x: auto; + border-collapse: collapse; + border-spacing: 0; + color: var(--text-muted); + font-size: 0.92rem; + line-height: 1.58; +} + +.markdown-body th, +.markdown-body td, +.typedoc-html th, +.typedoc-html td { + min-width: 120px; + border: 1px solid var(--border-subtle); + padding: 9px 11px; + vertical-align: top; +} + +.markdown-body th, +.typedoc-html th { + background: var(--surface-muted); + color: var(--text); + font-weight: 760; + text-align: left; +} + +.markdown-body td, +.typedoc-html td { + background: color-mix(in srgb, var(--surface) 88%, transparent); +} + +.markdown-body td:first-child, +.markdown-body th:first-child { + min-width: 150px; +} + +.markdown-body ul, +.markdown-body ol { + padding-left: 1.25rem; +} + +.markdown-body li + li { + margin-top: 5px; +} + +.markdown-body code:not(pre code), +.typedoc-html code:not(pre code) { + border: 1px solid var(--border); + border-radius: 5px; + background: var(--surface-muted); + color: var(--text-soft); + padding: 0.1rem 0.32rem; + font-size: 0.9em; +} + +.markdown-body pre { + width: min(100%, 920px); + margin: 18px 0 24px; + border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + border-radius: var(--radius); + box-shadow: var(--shadow-soft); +} + +.code-block-shell { + --code-copy-bg: color-mix(in srgb, var(--code-bg) 82%, var(--code-text)); + --code-copy-border: color-mix(in srgb, var(--code-text) 24%, transparent); + --code-copy-text: var(--code-text); + --code-copy-hover-bg: color-mix(in srgb, var(--code-bg) 68%, var(--code-text)); + --code-copy-failed-text: var(--danger-on-scene); + position: relative; + width: min(100%, 920px); + margin: 18px 0 24px; +} + +.code-block-shell pre { + width: 100%; + margin: 0; + padding-right: 86px; +} + +.typedoc-html .code-block-shell { + --code-copy-bg: color-mix(in srgb, var(--surface) 92%, transparent); + --code-copy-border: var(--typedoc-line); + --code-copy-text: var(--text-soft); + --code-copy-hover-bg: var(--surface); + --code-copy-failed-text: var(--danger); + width: min(100%, 980px); + margin: 12px 0 18px; +} + +.typedoc-html .code-block-shell pre { + width: 100%; + margin: 0; + padding-right: 86px; +} + +.code-copy-button { + position: absolute; + z-index: 2; + top: 10px; + right: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 64px; + min-height: 28px; + border: 1px solid var(--code-copy-border); + border-radius: var(--radius-sm); + background: var(--code-copy-bg); + color: var(--code-copy-text); + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22); + cursor: pointer; + font-family: var(--font-sans); + font-size: 0.74rem; + font-weight: 760; + line-height: 1; + opacity: 0; + padding: 0 9px; + pointer-events: none; + transform: translateY(-2px); + transition: + background 160ms ease, + border-color 160ms ease, + color 160ms ease, + opacity 160ms ease, + transform 160ms ease; +} + +.code-copy-button::before { + width: 9px; + height: 11px; + flex: 0 0 auto; + border: 1.4px solid currentColor; + border-radius: 2px; + box-shadow: + -3px -3px 0 -1px var(--code-copy-bg), + -3px -3px 0 0 currentColor; + content: ''; +} + +.code-copy-button:hover { + background: var(--code-copy-hover-bg); +} + +.code-block-shell:hover .code-copy-button, +.code-block-shell:focus-within .code-copy-button, +.code-copy-button[data-copy-state='copied'], +.code-copy-button[data-copy-state='failed'] { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.code-copy-button[data-copy-state='copied']::before { + width: 10px; + height: 6px; + border: 0; + border-bottom: 2px solid currentColor; + border-left: 2px solid currentColor; + border-radius: 0; + box-shadow: none; + transform: rotate(-45deg) translateY(-1px); +} + +.code-copy-button[data-copy-state='failed'] { + color: var(--code-copy-failed-text); +} + +.markdown-body pre::selection, +.markdown-body pre ::selection, +.typedoc-html pre::selection, +.typedoc-html pre ::selection, +.typedoc-html .tsd-signature::selection, +.typedoc-html .tsd-signature ::selection { + background: var(--code-selection-bg); + color: var(--code-selection-text); +} + +.typedoc-html { + --typedoc-code-bg: color-mix(in srgb, var(--surface-muted) 76%, var(--surface)); + --typedoc-line: color-mix(in srgb, var(--border) 72%, transparent); +} + +.typedoc-html :where(h2, h3, h4) { + scroll-margin-top: 96px; +} + +.typedoc-html h2 { + margin: 18px 0 10px; + color: var(--text); + font-size: 1.28rem; + font-weight: 780; + line-height: 1.28; +} + +.typedoc-html h3 { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 0 0 12px; + color: var(--text); + font-family: var(--font-mono); + font-size: 0.98rem; + font-weight: 760; + line-height: 1.45; +} + +.typedoc-html h4, +.typedoc-html .tsd-parameters-title, +.typedoc-html .tsd-returns-title { + margin: 16px 0 8px; + color: var(--text); + font-size: 0.82rem; + font-weight: 800; + letter-spacing: 0.05em; + line-height: 1.35; + text-transform: uppercase; +} + +.typedoc-html p, +.typedoc-html li { + min-width: 0; + color: var(--text-muted); + font-size: 0.96rem; + line-height: 1.72; + overflow-wrap: anywhere; +} + +.typedoc-html p { + margin: 0 0 12px; +} + +.typedoc-html p:last-child { + margin-bottom: 0; +} + +.typedoc-html a { + color: var(--primary-strong); + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; +} + +.typedoc-html a:hover { + color: var(--accent); +} + +.typedoc-html ul, +.typedoc-html ol { + padding-left: 1.2rem; +} + +.typedoc-html li + li { + margin-top: 5px; +} + +.typedoc-html code:not(pre code), +.typedoc-html .tsd-signature { + font-family: var(--font-mono); +} + +.typedoc-html pre { + width: min(100%, 980px); + margin: 12px 0 18px; + overflow-x: auto; + border: 1px solid var(--typedoc-line); + border-radius: var(--radius); + background: var(--typedoc-code-bg); + box-shadow: var(--shadow-soft); + padding: 14px 16px; +} + +.typedoc-html table { + display: block; + width: 100%; + max-width: 100%; + margin: 16px 0 22px; + overflow-x: auto; + border-collapse: collapse; + border-spacing: 0; + color: var(--text-muted); + font-size: 0.9rem; + line-height: 1.58; +} + +.typedoc-html .tsd-panel, +.typedoc-html .tsd-panel-group, +.typedoc-html .tsd-member, +.typedoc-html .tsd-signature, +.typedoc-html .tsd-parameters, +.typedoc-html .tsd-type-parameters, +.typedoc-html .tsd-description, +.typedoc-html .tsd-comment, +.typedoc-html .tsd-sources, +.typedoc-html .tsd-returns-title { + min-width: 0; +} + +.typedoc-html .tsd-member > *, +.typedoc-html .tsd-description > *, +.typedoc-html .tsd-comment > *, +.typedoc-html .tsd-parameters > *, +.typedoc-html .tsd-signatures > li > * { + min-width: 0; + max-width: 100%; +} + +.typedoc-html > .tsd-panel, +.typedoc-html > .tsd-signature { + margin: 0; +} + +.typedoc-html > .tsd-panel.tsd-comment { + border-left: 3px solid var(--primary); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--primary-soft) 54%, transparent); + padding: 14px 16px; +} + +.typedoc-html > .tsd-panel.tsd-comment p { + color: var(--text-soft); +} + +.typedoc-html > .tsd-panel.tsd-hierarchy { + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + background: color-mix(in srgb, var(--surface) 90%, transparent); + padding: 14px 16px; +} + +.typedoc-html ul.tsd-hierarchy { + display: grid; + gap: 6px; + list-style: none; + margin: 8px 0 0; + padding: 0; +} + +.typedoc-html ul.tsd-hierarchy ul { + margin: 6px 0 0 12px; + border-left: 1px solid var(--border-subtle); + padding-left: 12px; +} + +.typedoc-html .tsd-hierarchy-target { + color: var(--text); + font-family: var(--font-mono); + font-size: 0.9rem; +} + +.typedoc-html .tsd-member-group { + display: grid; + gap: 12px; + margin-top: 10px; +} + +.typedoc-html .tsd-member-group > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + cursor: pointer; + list-style: none; + border-top: 1px solid var(--border); + padding: 22px 0 2px; +} + +.typedoc-html .tsd-member-group > summary::-webkit-details-marker { + display: none; +} + +.typedoc-html .tsd-member-group > summary::after { + content: ''; + width: 8px; + height: 8px; + flex: 0 0 auto; + border-right: 1.5px solid var(--text-muted); + border-bottom: 1.5px solid var(--text-muted); + transform: rotate(45deg) translateY(-2px); + transition: transform 160ms ease; +} + +.typedoc-html .tsd-member-group > summary h2 { + margin: 0; + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 840; + letter-spacing: 0.08em; + line-height: 1.2; + text-transform: uppercase; +} + +.typedoc-html .tsd-member-group > section { + display: grid; + gap: 12px; +} + +.typedoc-html .tsd-member { + display: grid; + gap: 10px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + background: color-mix(in srgb, var(--surface) 94%, transparent); + padding: 15px 16px; +} + +.typedoc-html .tsd-member.tsd-is-inherited { + border-style: dashed; + background: color-mix(in srgb, var(--surface-muted) 34%, transparent); +} + +.typedoc-html .tsd-member.tsd-is-inherited > h3::after { + content: 'Inherited'; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-pill); + background: var(--surface); + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 0.68rem; + font-weight: 760; + letter-spacing: 0; + padding: 2px 7px; +} + +.typedoc-html .tsd-member:has(> .tsd-type-declaration) > .tsd-signature { + display: none; +} + +.typedoc-html .tsd-signature { + display: block; + max-width: 100%; + margin: 0; + overflow: auto; + border: 1px solid var(--typedoc-line); + border-radius: var(--radius-sm); + background: var(--typedoc-code-bg); + color: var(--text-soft); + padding: 11px 12px; + font-size: 0.86rem; + line-height: 1.68; + tab-size: 4; + white-space: pre-wrap; +} + +.typedoc-html > .tsd-signature { + max-height: min(54vh, 520px); +} + +.typedoc-html .tsd-signatures, +.typedoc-html .tsd-type-declaration > .tsd-parameters { + display: grid; + gap: 12px; + list-style: none; + margin: 0; + padding: 0; +} + +.typedoc-html .tsd-signatures > li { + display: grid; + gap: 12px; + min-width: 0; +} + +.typedoc-html .tsd-signatures > li.tsd-signature { + display: block; + gap: 0; +} + +.typedoc-html .tsd-signatures > li + li { + border-top: 1px solid var(--border-subtle); + padding-top: 12px; +} + +.typedoc-html .tsd-description { + display: grid; + gap: 10px; + color: var(--text-muted); +} + +.typedoc-html .tsd-description > :first-child, +.typedoc-html .tsd-comment > :first-child { + margin-top: 0; +} + +.typedoc-html .tsd-description > :last-child, +.typedoc-html .tsd-comment > :last-child { + margin-bottom: 0; +} + +.typedoc-html .tsd-type-declaration { + display: grid; + gap: 10px; + min-width: 0; + border-top: 1px solid var(--border-subtle); + background: transparent; + padding: 12px 0 0; +} + +.typedoc-html .tsd-type-declaration > h4 { + display: none; +} + +.typedoc-html .tsd-type-declaration > .tsd-parameters > li { + min-width: 0; + margin: 0; +} + +.typedoc-html .tsd-type-declaration .tsd-parameter-signature { + display: grid; + gap: 10px; +} + +.typedoc-html .tsd-type-declaration .tsd-signatures > li + li { + border-top: 0; + padding-top: 0; +} + +.typedoc-html .tsd-type-declaration .tsd-parameter { + display: grid; + gap: 7px; + min-width: 0; + border-top: 1px solid var(--border-subtle); + padding: 10px 0 11px; +} + +.typedoc-html .tsd-type-declaration .tsd-parameter:first-child { + border-top: 0; + padding-top: 0; +} + +.typedoc-html .tsd-type-declaration h5 { + display: block; + margin: 0; + color: var(--text-soft); + font-family: var(--font-mono); + font-size: 0.84rem; + font-weight: 650; + line-height: 1.75; + overflow-wrap: anywhere; +} + +.typedoc-html .tsd-type-declaration .tsd-comment { + margin: 0; +} + +.typedoc-html .tsd-type-declaration .tsd-comment p { + color: var(--text-muted); + font-size: 0.9rem; + line-height: 1.62; +} + +.typedoc-html .tsd-type-declaration .tsd-default-value { + float: right; + max-width: min(50%, 28rem); + margin: 0 0 4px 12px; + font-size: 0.74rem; +} + +.typedoc-html .tsd-default-value { + display: inline-flex; + align-items: center; + max-width: 100%; + border-color: var(--border-subtle); + background: var(--surface-muted); + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 620; + line-height: 1.35; + overflow-wrap: anywhere; + white-space: normal; +} + +.typedoc-html h3 > .tsd-default-value { + max-width: min(46%, 28rem); + margin-left: auto; +} + +.typedoc-html .tsd-parameters, +.typedoc-html .tsd-panel:has(> .tsd-type-parameter-list) { + margin: 2px 0 0; +} + +.typedoc-html .tsd-parameter-list, +.typedoc-html .tsd-type-parameter-list { + display: grid; + gap: 8px; + list-style: none; + margin: 8px 0 0; + padding: 0; +} + +.typedoc-html .tsd-parameter-list > li, +.typedoc-html .tsd-type-parameter-list > li { + min-width: 0; + border-left: 2px solid var(--border); + padding: 2px 0 2px 12px; +} + +.typedoc-html .tsd-parameter-list > li > span:first-child, +.typedoc-html .tsd-type-parameter-list > li > span:first-child { + display: block; + color: var(--text-soft); + font-family: var(--font-mono); + font-size: 0.86rem; + line-height: 1.65; +} + +.typedoc-html .tsd-sources { + margin-top: 4px; + border-top: 1px solid var(--border-subtle); + padding-top: 9px; + color: var(--text-muted); + font-size: 0.78rem; +} + +.typedoc-html .tsd-sources p { + font-size: inherit; + line-height: 1.55; +} + +.typedoc-html .tsd-anchor-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + color: var(--text-muted); + opacity: 0; + text-decoration: none; + transition: opacity 140ms ease; +} + +.typedoc-html .tsd-anchor-icon::before { + content: '#'; + font-family: var(--font-sans); + font-size: 0.72rem; + font-weight: 780; +} + +.typedoc-html :where(h3, h4, .tsd-signature):hover .tsd-anchor-icon, +.typedoc-html .tsd-anchor-icon:focus-visible { + opacity: 0.72; +} + +.typedoc-html .tsd-signature-keyword, +.typedoc-html .tsd-signature-symbol { + color: var(--text-muted); +} + +.typedoc-html .tsd-kind-parameter, +.typedoc-html .tsd-kind-type-parameter { + color: var(--code-param); +} + +.typedoc-html .tsd-signature-type { + color: var(--code-type); +} + +.typedoc-html .tsd-kind-class, +.typedoc-html .tsd-kind-interface, +.typedoc-html .tsd-kind-enum, +.typedoc-html .tsd-kind-type-alias, +.typedoc-html .tsd-kind-variable, +.typedoc-html .tsd-kind-property, +.typedoc-html .tsd-kind-enum-member { + color: var(--code-type); + font-weight: 700; +} + +.typedoc-html .tsd-kind-function, +.typedoc-html .tsd-kind-method, +.typedoc-html .tsd-kind-call-signature, +.typedoc-html .tsd-kind-get-signature, +.typedoc-html .tsd-kind-set-signature, +.typedoc-html .tsd-kind-constructor-signature { + color: var(--code-func); + font-weight: 700; +} + +.typedoc-html .tsd-tag, +.typedoc-html .tsd-flag { + display: inline-flex; + align-items: center; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-pill); + background: var(--surface-muted); + color: var(--text-muted); + padding: 2px 7px; + font-family: var(--font-sans); + font-size: 0.68rem; + font-weight: 760; + line-height: 1.2; +} + +.typedoc-html .deprecated, +.typedoc-html .tsd-tag-deprecated { + color: var(--danger); +} + +@media (max-width: 900px) { + .docs-shell { + grid-template-columns: 1fr; + gap: 22px; + padding: 26px 16px 46px; + } + + .docs-shell.has-toc { + grid-template-columns: 1fr; + } + + .docs-shell > .docs-sidebar, + .docs-shell > .doc-toc { + display: none; + } + + .docs-content { + padding: 0; + } + + .docs-heading { + padding-bottom: 20px; + } + + .docs-heading h1 { + font-size: 2.35rem; + } + + .site-drawer-nav-context .docs-sidebar { + position: static; + max-height: none; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + } + + .site-drawer-nav-context .docs-sidebar-search { + position: static; + border-bottom-color: var(--border-subtle); + background: transparent; + } + + .site-drawer-nav-context .docs-sidebar nav { + padding: 8px 0 2px; + } + + .site-drawer-nav-context .docs-sidebar-active-content { + min-width: 0; + padding: 6px 0 10px 10px; + } + + .site-drawer-nav-context .doc-toc { + position: static; + max-height: none; + margin-top: 0; + border-top: 1px solid var(--border-subtle); + border-left: 0; + padding: 12px 0 0; + } + + .doc-pager { + grid-template-columns: 1fr; + } + + .doc-pager div:last-child { + text-align: left; + } +} + +@media (max-width: 560px) { + .docs-shell { + max-width: calc(100vw - 24px); + padding: 32px 12px 44px; + } + + .docs-heading h1 { + font-size: 2rem; + } + + .docs-heading p { + font-size: 0.98rem; + } + + .markdown-body img { + max-height: 56vh; + border-radius: 6px; + } + + .code-block-shell pre, + .typedoc-html .code-block-shell pre { + padding-right: 78px; + } + + .code-copy-button { + top: 8px; + right: 8px; + min-width: 58px; + min-height: 26px; + padding: 0 8px; + font-size: 0.7rem; + } +} diff --git a/website/src/styles/examples.css b/website/src/styles/examples.css new file mode 100644 index 0000000..d4f5619 --- /dev/null +++ b/website/src/styles/examples.css @@ -0,0 +1,367 @@ +.example-viewer-page { + background: var(--bg); +} + +.example-viewer { + --example-rail-width: var(--example-initial-rail-width, 286px); + position: relative; + display: grid; + grid-template-columns: minmax(220px, var(--example-rail-width)) var(--workspace-splitter-size) minmax(420px, 1fr); + height: calc(100svh - var(--header-height)); + min-height: var(--workspace-min-height); + border-top: 1px solid var(--border-subtle); +} + +.example-viewer[data-layout-ready='true'] { + transition: grid-template-columns 180ms ease; +} + +:root[data-example-rail-collapsed='true'] .example-viewer:not([data-mounted='true']) { + grid-template-columns: 0 var(--workspace-splitter-expanded-size) minmax(0, 1fr); +} + +.example-viewer[data-resizing='true'] { + cursor: col-resize; + user-select: none; + transition: none; +} + +.example-rail { + min-width: 0; + min-height: var(--workspace-min-height); + overflow: auto; + border-right: 1px solid var(--border); + background: linear-gradient(180deg, var(--surface), var(--surface-subtle)), var(--surface); + transition: + border-color 180ms ease, + opacity 160ms ease, + visibility 160ms ease; +} + +:root[data-example-rail-collapsed='true'] .example-viewer:not([data-mounted='true']) .example-rail { + visibility: hidden; + overflow: hidden; + opacity: 0; + border-right-color: transparent; + pointer-events: none; +} + +.example-rail nav { + display: grid; + gap: 10px; + padding: 12px 10px 22px; +} + +.example-rail a { + --rail-accent: var(--example-accent); + display: grid; + grid-template-columns: 84px minmax(0, 1fr); + grid-template-rows: auto auto; + align-items: center; + align-content: center; + column-gap: 10px; + row-gap: 9px; + min-height: 76px; + padding: 8px; + border: 1px solid transparent; + border-radius: var(--radius); + color: var(--text); + transition: + background 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; +} + +.example-rail a:hover, +.example-rail a.is-active { + border-color: color-mix(in srgb, var(--rail-accent) 34%, var(--border)); + background: color-mix(in srgb, var(--rail-accent) 7%, var(--surface)); + box-shadow: var(--shadow-soft); +} + +.example-rail a:hover { + transform: translateY(-1px); +} + +.example-rail a.is-active { + box-shadow: + inset 3px 0 0 var(--rail-accent), + var(--shadow-soft); +} + +.example-thumb { + position: relative; + display: block; + grid-row: 1 / span 2; + aspect-ratio: 1.42; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--rail-accent) 34%, transparent); + border-radius: var(--radius-sm); + background: + linear-gradient( + 135deg, + color-mix(in srgb, var(--rail-accent) 34%, var(--color-ink-950)), + var(--color-ink-900) 56%, + var(--color-ink-800) + ), + linear-gradient(var(--scene-grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--scene-grid-line) 1px, transparent 1px); + background-size: + auto, + 16px 16px, + 16px 16px; +} + +.example-thumb::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(180deg, transparent 48%, color-mix(in srgb, var(--color-black) 46%, transparent)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-white) 12%, transparent); + pointer-events: none; +} + +.example-thumb img { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.example-rail a.is-active .example-thumb { + border-color: var(--rail-accent); +} + +.example-rail a > strong { + min-width: 0; + overflow: hidden; + font-size: 0.92rem; + font-weight: 760; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.example-rail-tags { + display: flex; + flex-wrap: wrap; + grid-column: 2; + gap: 6px; + overflow: hidden; + max-height: 24px; +} + +.example-rail-tags span { + border: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + border-radius: var(--radius-pill); + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 700; + line-height: 1; + padding: 5px 7px; +} + +:root[data-example-rail-collapsed='true'] .example-viewer:not([data-mounted='true']) .example-splitter { + cursor: e-resize; + background: transparent; +} + +:root[data-example-rail-collapsed='true'] .example-viewer:not([data-mounted='true']) .example-splitter::before { + inset: 12px 5px; + opacity: 0.72; +} + +.example-stage { + position: relative; + min-width: 0; + min-height: var(--workspace-min-height); + overflow: hidden; + background: var(--scene-bg); +} + +.example-stage .example-preview { + position: relative; + height: 100%; + min-height: 100%; + overflow: hidden; + background: var(--scene-bg); +} + +.example-stage .example-preview canvas, +.example-stage .example-preview .renderer-runtime-surface { + position: absolute; + inset: 0; + z-index: 1; + display: block; + width: 100%; + height: 100%; + background: var(--scene-bg); +} + +.example-preview[data-state='loading'] canvas, +.example-preview[data-state='loading'] .renderer-runtime-surface { + opacity: 0; +} + +.example-preview-status { + position: absolute; + top: 50%; + left: 50%; + z-index: 3; + max-width: min(420px, calc(100% - 40px)); + color: var(--scene-text); + font-size: 0.82rem; + font-weight: 700; + line-height: 1.35; + text-align: center; + text-shadow: 0 1px 10px var(--scene-text-shadow); + transform: translate(-50%, -50%); +} + +.example-preview[data-state='error'] .example-preview-status { + color: var(--danger-on-scene); +} + +.example-preview[data-state='error'] canvas { + opacity: 0.32; +} + +.example-config-panel { + position: absolute; + top: 14px; + right: 14px; + z-index: 7; + width: min(304px, calc(100% - 28px)); + max-height: calc(100% - 100px); + overflow: auto; +} + +.example-edit-button { + position: absolute; + right: 22px; + bottom: 22px; + z-index: 6; + display: inline-flex; + align-items: center; + justify-content: center; + width: 50px; + height: 50px; + border: 1px solid var(--scene-control-border); + border-radius: 50%; + background: var(--scene-control-bg); + box-shadow: var(--scene-control-shadow); + transition: + background 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; +} + +.example-edit-button:hover { + border-color: var(--color-white); + background: var(--color-white); + box-shadow: var(--scene-control-shadow-hover); + transform: translateY(-1px); +} + +.example-edit-button:active { + transform: translateY(0); +} + +.example-edit-button img { + display: block; + width: 24px; + height: 24px; + opacity: 0.9; + pointer-events: none; +} + +@media (max-width: 900px) { + .example-viewer-page { + overflow: auto; + } + + .example-viewer, + .example-viewer[data-pane-collapsed='true'], + :root[data-example-rail-collapsed='true'] .example-viewer:not([data-mounted='true']) { + grid-template-columns: 1fr; + grid-template-rows: minmax(500px, 1fr); + height: auto; + min-height: calc(100svh - var(--header-height-compact)); + } + + .example-viewer > .example-rail, + .example-splitter.workspace-resizer { + display: none; + } + + .site-drawer-nav-context .example-mobile-rail { + min-height: 0; + overflow: visible; + border-right: 0; + border-bottom: 0; + background: transparent; + } + + .site-drawer-nav-context .example-mobile-rail nav { + width: auto; + min-width: 0; + gap: 8px; + padding: 0; + } + + .site-drawer-nav-context .example-mobile-rail a { + width: 100%; + grid-template-columns: 74px minmax(0, 1fr); + } + + .example-stage { + height: calc(100svh - var(--header-height-compact)); + min-height: 500px; + } +} + +@media (max-width: 560px) { + .example-stage { + min-height: 480px; + } + + .example-edit-button { + right: 14px; + bottom: 14px; + width: 48px; + height: 48px; + } + + .example-config-panel { + top: 10px; + right: 10px; + width: min(292px, calc(100% - 20px)); + max-height: calc(100% - 94px); + } +} + +:root[data-workspace-fullscreen='true'] .example-viewer { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + height: 100dvh; + min-height: 100dvh; + border-top: 0; +} + +:root[data-workspace-fullscreen='true'] .example-rail, +:root[data-workspace-fullscreen='true'] .example-splitter, +:root[data-workspace-fullscreen='true'] .example-edit-button, +:root[data-workspace-fullscreen='true'] .example-config-panel, +:root[data-workspace-fullscreen='true'] .viewer-interaction-guide { + display: none; +} + +:root[data-workspace-fullscreen='true'] .example-stage { + height: 100dvh; + min-height: 100dvh; +} diff --git a/website/src/styles/global.css b/website/src/styles/global.css new file mode 100644 index 0000000..255243e --- /dev/null +++ b/website/src/styles/global.css @@ -0,0 +1,102 @@ +* { + box-sizing: border-box; +} + +html { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + line-height: 1.5; + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + min-width: 320px; + background: var(--page-bg); +} + +a { + color: inherit; + text-decoration: none; +} + +::selection { + background: color-mix(in srgb, var(--text) 14%, transparent); +} + +button, +select, +textarea { + font: inherit; +} + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 3px; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--control-height); + padding: 0 15px; + border: 1px solid var(--control-border); + border-radius: var(--radius-sm); + background: var(--control-bg); + color: var(--text); + box-shadow: var(--shadow-soft); + cursor: pointer; + font-weight: 680; + text-align: center; + transition: + border-color 160ms ease, + background 160ms ease, + box-shadow 160ms ease, + color 160ms ease, + transform 160ms ease; +} + +.button:hover { + border-color: var(--control-hover-border); + color: var(--primary-strong); +} + +.button:active { + transform: translateY(1px); +} + +.button.primary { + border-color: var(--primary-strong); + background: var(--primary-strong); + color: var(--primary-contrast); + box-shadow: var(--control-primary-shadow); +} + +.button.primary:hover { + border-color: var(--primary-hover); + background: var(--primary-hover); + color: var(--primary-contrast); +} + +.button.compact { + min-height: var(--control-height-sm); + padding: 0 12px; + font-size: 0.88rem; +} + +pre { + margin: 0; + overflow: auto; + padding: 18px; + background: var(--code-bg); + color: var(--code-text); + font-family: var(--font-mono); + font-size: 0.9rem; + line-height: 1.72; +} + +code { + font-family: var(--font-mono); +} diff --git a/website/src/styles/home.css b/website/src/styles/home.css new file mode 100644 index 0000000..f2ff43c --- /dev/null +++ b/website/src/styles/home.css @@ -0,0 +1,1057 @@ +.home-page { + overflow-x: hidden; + background: var(--bg); + color: var(--text); +} + +html.home-interactive, +.home-page.home-interactive { + overflow: hidden; + overscroll-behavior: none; + scrollbar-width: none; +} + +html.home-interactive::-webkit-scrollbar, +.home-page.home-interactive::-webkit-scrollbar { + width: 0; + height: 0; +} + +.home-stage { + --home-screen-height: 100vh; + --home-tilt-x: 0deg; + --home-tilt-y: 0deg; + --home-overlay-opacity: 0.56; + --home-canvas-preview-opacity: 0.58; + --home-canvas-enter-start-opacity: 0.55; + position: relative; + isolation: isolate; + min-height: var(--home-screen-height); + overflow: hidden; + border-bottom: 1px solid var(--border-subtle); + background: + linear-gradient(180deg, color-mix(in srgb, var(--surface-subtle) 92%, transparent), var(--bg) 72%), var(--bg); +} + +@supports (height: 100dvh) { + .home-stage { + --home-screen-height: 100dvh; + } +} + +.home-stage::before, +.home-stage::after { + content: ''; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; +} + +.home-stage::before { + background: + linear-gradient(color-mix(in srgb, var(--border) 46%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--border) 46%, transparent) 1px, transparent 1px); + background-size: 44px 44px; + mask-image: linear-gradient(180deg, transparent, var(--color-black) 14%, var(--color-black) 72%, transparent); + opacity: 0.42; + transform: scale(1); + transform-origin: center; + transition: + opacity 520ms ease, + transform 760ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.home-stage::after { + background: + linear-gradient( + 90deg, + var(--bg) 0%, + color-mix(in srgb, var(--bg) 88%, transparent) 44%, + color-mix(in srgb, var(--bg) 36%, transparent) + ), + linear-gradient(180deg, color-mix(in srgb, var(--bg) 46%, transparent), var(--bg)); + opacity: var(--home-overlay-opacity); + transform: scale(1); + transform-origin: center; + transition: + opacity 520ms ease, + transform 760ms cubic-bezier(0.16, 1, 0.3, 1); +} + +:root[data-theme='dark'] .home-stage { + --home-overlay-opacity: 0.58; + --home-canvas-preview-opacity: 0.72; + --home-canvas-enter-start-opacity: 0.7; +} + +:root[data-theme='dark'] .home-stage::after { + background: + linear-gradient( + 90deg, + var(--bg) 0%, + color-mix(in srgb, var(--bg) 78%, transparent) 46%, + color-mix(in srgb, var(--bg) 20%, transparent) + ), + linear-gradient(180deg, color-mix(in srgb, var(--bg) 38%, transparent), var(--bg)); +} + +.home-stage[data-interactive='true']::after { + opacity: 0; +} + +.home-stage[data-interactive='true']::before { + opacity: 0.2; + transform: scale(1.08); +} + +.home-stage[data-transition='enter']::before { + animation: home-grid-enter 820ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.home-stage[data-transition='enter']::after { + animation: home-overlay-enter 820ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +:root[data-theme='dark'] .home-stage[data-transition='enter']::after { + animation: none; + opacity: 0; + transform: scale(1.04); + transition: none; +} + +.home-page .site-header { + position: fixed; + top: 0; + right: 0; + left: 0; + transition: + opacity 180ms ease, + transform 180ms ease, + visibility 180ms ease; +} + +.home-page.home-interactive .site-header { + visibility: hidden; + opacity: 0; + transform: translateY(-100%); + pointer-events: none; +} + +.home-page.home-interactive .home-stage, +.home-page.home-interactive .home-hero { + height: var(--home-screen-height); + min-height: var(--home-screen-height); +} + +.home-page.home-interactive .home-stage { + position: fixed; + inset: 0; + z-index: 60; + width: 100vw; + max-width: none; + border-bottom: 0; +} + +.home-canvas { + position: absolute; + inset: 0; + z-index: 0; + display: block; + width: 100%; + height: 100%; + opacity: var(--home-canvas-preview-opacity); + filter: saturate(0.9); + mix-blend-mode: multiply; + pointer-events: none; + transform: perspective(1200px) rotateX(var(--home-tilt-x)) rotateY(var(--home-tilt-y)) scale(1.02); + transform-origin: center; + transition: + filter 420ms ease, + opacity 420ms ease, + transform 760ms cubic-bezier(0.16, 1, 0.3, 1); + will-change: filter, opacity, transform; +} + +:root[data-theme='dark'] .home-canvas { + filter: saturate(1); + mix-blend-mode: normal; +} + +.home-stage[data-interactive='true'] .home-canvas { + cursor: grab; + opacity: 1; + filter: none; + mix-blend-mode: normal; + pointer-events: auto; + transform: perspective(1200px) rotateX(var(--home-tilt-x)) rotateY(var(--home-tilt-y)) scale(1); +} + +.home-stage[data-transition='enter'] .home-canvas { + animation: home-canvas-enter 820ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.home-stage[data-interactive='true'] .home-canvas:active { + cursor: grabbing; +} + +.home-hero { + position: relative; + z-index: 1; + display: grid; + align-items: center; + min-height: var(--home-screen-height); + padding: 72px 24px 56px; + color: var(--text); + filter: blur(0); + opacity: 1; + transform: translateY(0); + transition: + filter 420ms ease, + opacity 300ms ease, + transform 620ms cubic-bezier(0.16, 1, 0.3, 1), + visibility 300ms ease; + will-change: filter, opacity, transform; +} + +.home-stage[data-interactive='true'] .home-hero { + visibility: hidden; + filter: blur(7px); + opacity: 0; + transform: translateY(-24px) scale(0.96); + pointer-events: none; +} + +.home-stage[data-transition='enter'] .home-hero { + animation: home-hero-enter 560ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.home-hero-inner { + display: grid; + justify-items: start; + width: min(100%, var(--content)); + margin: 0 auto; + text-align: left; +} + +.home-title { + margin: 0; + color: var(--text); + font-size: clamp(2.6rem, 7vw, 6.2rem); + font-weight: 700; + line-height: 1.02; + letter-spacing: -0.022em; +} + +@supports (-webkit-background-clip: text) { + .home-title { + background: linear-gradient(180deg, var(--text) 52%, color-mix(in srgb, var(--text) 68%, transparent)); + background-clip: text; + -webkit-background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + } +} + +.home-hero strong { + display: block; + max-width: 620px; + margin: 24px 0 0; + color: var(--text-soft); + font-size: 1.16rem; + font-weight: 540; + line-height: 1.62; +} + +.home-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 40px; +} + +.home-action-button { + min-height: 52px; + padding: 0 30px; + border-radius: var(--radius-pill); + font-size: 1.02rem; + font-weight: 700; +} + +.home-started { + border-color: color-mix(in srgb, var(--text) 26%, transparent); + background: color-mix(in srgb, var(--surface) 42%, transparent); + color: var(--text); + backdrop-filter: blur(16px); +} + +.home-started:hover, +.home-started:focus-visible { + border-color: color-mix(in srgb, var(--text) 48%, transparent); + background: color-mix(in srgb, var(--surface) 68%, transparent); + color: var(--text); +} + +@property --home-beam-angle { + syntax: ''; + inherits: false; + initial-value: 0deg; +} + +.home-try { + position: relative; + gap: 12px; +} + +/* A band of light gliding along the button border: colored tail, white-hot head, + and a blurred glow twin. The opaque button face hides the gradient interior, + so only the rim at the edge shows. */ +.home-try::before, +.home-try::after { + content: ''; + position: absolute; + inset: -2.5px; + z-index: -1; + border-radius: var(--radius-pill); + background: conic-gradient( + from var(--home-beam-angle), + transparent 0deg, + transparent 238deg, + rgba(139, 92, 246, 0.45) 282deg, + #8b5cf6 318deg, + #22d3ee 344deg, + rgba(255, 255, 255, 0.92) 356deg, + transparent 360deg + ); + animation: home-beam-spin 5s linear infinite; + pointer-events: none; +} + +.home-try::after { + inset: -3px; + filter: blur(12px); + opacity: 0.8; +} + +@keyframes home-beam-spin { + to { + --home-beam-angle: 360deg; + } +} + +.home-try span + span { + transition: transform 220ms cubic-bezier(0.25, 0.1, 0.25, 1); +} + +.home-try:hover span + span, +.home-try:focus-visible span + span { + transform: translateX(3px); +} + +.home-space-entry { + position: absolute; + right: calc(env(safe-area-inset-right, 0px) + 24px); + bottom: calc(env(safe-area-inset-bottom, 0px) + 24px); + z-index: 2; + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 0 12px; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--surface) 55%, transparent); + color: var(--text-muted); + cursor: pointer; + font: inherit; + font-size: 0.78rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; + opacity: 0.64; + transition: + border-color 180ms ease, + background 180ms ease, + color 180ms ease, + opacity 220ms ease, + visibility 220ms ease; + backdrop-filter: blur(10px); +} + +.home-space-entry-dot { + width: 5px; + height: 5px; + border-radius: var(--radius-pill); + background: currentColor; + animation: home-live-pulse 2.4s ease-in-out infinite; +} + +.home-space-entry-label { + letter-spacing: 0.02em; +} + +.home-space-entry:hover, +.home-space-entry:focus-visible { + border-color: color-mix(in srgb, var(--text) 30%, var(--border)); + background: color-mix(in srgb, var(--surface) 88%, transparent); + color: var(--text); + opacity: 1; +} + +.home-space-entry:focus-visible { + outline: 2px solid color-mix(in srgb, var(--text) 42%, transparent); + outline-offset: 4px; +} + +.home-stage[data-interactive='true'] .home-space-entry, +.home-stage[data-transition='enter'] .home-space-entry { + visibility: hidden; + opacity: 0; + pointer-events: none; +} + +@keyframes home-live-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + + 50% { + opacity: 0.45; + transform: scale(0.78); + } +} + +.home-specs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: stretch; + gap: 16px; + width: 100%; + margin-top: 72px; +} + +.home-spec { + display: grid; + align-content: start; + gap: 10px; + padding: 22px 24px 21px; + border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); + border-radius: 16px; + background: color-mix(in srgb, var(--surface) 52%, transparent); + box-shadow: + inset 0 1px 0 color-mix(in srgb, var(--text) 10%, transparent), + var(--shadow-soft); + backdrop-filter: blur(22px) saturate(1.25); + text-align: left; +} + +.home-spec span { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.7rem; + font-weight: 650; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.home-spec h2 { + margin: 0; + color: var(--text); + font-size: 1.24rem; + font-weight: 760; + line-height: 1.26; + letter-spacing: -0.01em; +} + +.home-spec p { + max-width: 320px; + margin: 0; + color: var(--text-soft); + font-size: 0.93rem; + font-weight: 520; + line-height: 1.58; +} + +.home-exit { + position: absolute; + top: calc(env(safe-area-inset-top, 0) + 16px); + left: calc(env(safe-area-inset-left, 0) + 16px); + z-index: 3; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--border) 74%, transparent); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--surface) 76%, transparent); + color: var(--text); + cursor: pointer; + font: inherit; + opacity: 0.58; + box-shadow: var(--shadow-soft); + transform: translateY(0) scale(1); + transition: + background 160ms ease, + border-color 160ms ease, + color 160ms ease, + opacity 220ms ease, + transform 220ms ease; + backdrop-filter: blur(14px); +} + +.home-stage[data-transition='enter'] .home-exit { + animation: home-exit-enter 460ms 360ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.home-exit:hover, +.home-exit:focus-visible { + border-color: color-mix(in srgb, var(--primary) 58%, var(--border)); + background: color-mix(in srgb, var(--surface) 94%, transparent); + color: var(--primary-strong); + opacity: 1; + transform: translateY(0) scale(1.04); +} + +.home-exit:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent); + outline-offset: 4px; +} + +.home-exit:active { + opacity: 1; + transform: translateY(0) scale(0.96); +} + +.home-exit-icon { + position: relative; + display: block; + width: 18px; + height: 18px; +} + +.home-exit-icon::before, +.home-exit-icon::after { + content: ''; + position: absolute; + top: 50%; +} + +.home-exit-icon::before { + left: 3px; + width: 14px; + height: 2px; + background: currentColor; + transform: translateY(-50%); +} + +.home-exit-icon::after { + left: 3px; + width: 9px; + height: 9px; + border-bottom: 2px solid currentColor; + border-left: 2px solid currentColor; + transform: translateY(-50%) rotate(45deg); +} + +.home-stage[data-interactive='false'] .home-exit, +.home-exit[hidden] { + display: none; +} + +@keyframes home-grid-enter { + 0% { + opacity: 0.42; + transform: scale(1); + } + + 100% { + opacity: 0.2; + transform: scale(1.08); + } +} + +@keyframes home-overlay-enter { + 0% { + opacity: var(--home-overlay-opacity); + transform: scale(1); + } + + 42% { + opacity: 0.08; + } + + 100% { + opacity: 0; + transform: scale(1.04); + } +} + +@keyframes home-canvas-enter { + 0% { + filter: saturate(0.86) blur(2px); + opacity: var(--home-canvas-enter-start-opacity); + transform: perspective(1200px) rotateX(var(--home-tilt-x)) rotateY(var(--home-tilt-y)) scale(1.12); + } + + 46% { + filter: saturate(1.04) blur(0); + opacity: 1; + } + + 100% { + filter: none; + opacity: 1; + transform: perspective(1200px) rotateX(var(--home-tilt-x)) rotateY(var(--home-tilt-y)) scale(1); + } +} + +@keyframes home-hero-enter { + 0% { + filter: blur(0); + opacity: 1; + transform: translateY(0) scale(1); + } + + 100% { + filter: blur(7px); + opacity: 0; + transform: translateY(-24px) scale(0.96); + } +} + +@keyframes home-exit-enter { + 0% { + opacity: 0; + transform: translateY(-8px) scale(0.92); + } + + 100% { + opacity: 0.58; + transform: translateY(0) scale(1); + } +} + +.home-features { + position: relative; + display: grid; + background: var(--bg); + color: var(--text); + counter-reset: home-feature; +} + +.home-feature { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 0.82fr); + gap: 64px; + align-items: center; + width: min(100%, var(--content)); + margin: 0 auto; + padding: 76px 24px; + border-top: 1px solid var(--border-subtle); +} + +.home-feature:nth-child(even) { + grid-template-columns: minmax(360px, 0.82fr) minmax(0, 1fr); +} + +.home-feature:nth-child(even) .home-feature-media { + order: 2; +} + +.home-feature-media { + position: relative; + display: block; + min-height: 340px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--scene-surface); + color: var(--scene-text); + box-shadow: + var(--shadow-soft), + inset 0 1px 0 var(--scene-panel-border-soft); + text-decoration: none; + transition: + border-color 180ms ease, + box-shadow 180ms ease; +} + +.home-feature-media:hover, +.home-feature-media:focus-visible { + border-color: color-mix(in srgb, var(--text) 32%, var(--border)); + box-shadow: + var(--shadow), + inset 0 1px 0 var(--scene-panel-border); +} + +.home-feature-media.is-disabled { + border-color: color-mix(in srgb, var(--border) 78%, transparent); + background: var(--scene-surface); + cursor: not-allowed; + box-shadow: inset 0 1px 0 var(--scene-panel-border-soft); +} + +.home-feature-media.is-disabled .home-feature-motion, +.home-feature-media.is-disabled .home-feature-enter { + cursor: not-allowed; +} + +.home-feature-media.is-disabled:hover, +.home-feature-media.is-disabled:focus-visible { + border-color: color-mix(in srgb, var(--text-muted) 42%, var(--border)); + box-shadow: inset 0 1px 0 var(--scene-panel-border-soft); + transform: none; +} + +.home-feature-media:focus-visible { + outline: 2px solid color-mix(in srgb, var(--text) 42%, transparent); + outline-offset: 5px; +} + +.home-feature-motion { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.92; + transform: scale(1); + transition: + opacity 180ms ease, + transform 420ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.home-feature-media:hover .home-feature-motion, +.home-feature-media:focus-visible .home-feature-motion { + opacity: 0.64; + transform: scale(1.04); +} + +.home-feature-media.is-disabled:hover .home-feature-motion, +.home-feature-media.is-disabled:focus-visible .home-feature-motion { + opacity: 0.92; + transform: scale(1); +} + +.home-feature-media::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--scene-panel-bg) 8%, transparent), + var(--scene-overlay-strong) + ); + opacity: 0; + transition: opacity 180ms ease; +} + +.home-feature-media:hover::after, +.home-feature-media:focus-visible::after { + opacity: 1; +} + +.home-feature-media.is-disabled:hover::after, +.home-feature-media.is-disabled:focus-visible::after { + opacity: 0; +} + +.home-feature-enter { + position: absolute; + right: 18px; + bottom: 18px; + z-index: 2; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 40px; + max-width: calc(100% - 36px); + padding: 0 13px 0 16px; + border: 1px solid color-mix(in srgb, var(--scene-text) 32%, var(--scene-panel-border)); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--scene-panel-bg) 86%, transparent); + color: var(--scene-text); + font-size: 0.88rem; + font-weight: 800; + line-height: 1; + opacity: 0.94; + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.3); + white-space: nowrap; + transform: translateY(0); + transition: + border-color 180ms ease, + background 180ms ease, + opacity 180ms ease, + transform 180ms ease, + box-shadow 180ms ease; + backdrop-filter: blur(14px); +} + +.home-feature-media:not(.is-disabled) .home-feature-enter::after { + content: ''; + width: 7px; + height: 7px; + border-top: 2px solid currentColor; + border-right: 2px solid currentColor; + transform: rotate(45deg); + transition: transform 180ms ease; +} + +.home-feature-media.is-disabled .home-feature-enter { + border-color: color-mix(in srgb, var(--text-muted) 46%, var(--scene-panel-border)); + background: color-mix(in srgb, var(--scene-panel-bg) 72%, transparent); + color: color-mix(in srgb, var(--scene-text) 72%, var(--text-muted)); + box-shadow: none; + opacity: 0.82; +} + +.home-feature-media:hover .home-feature-enter, +.home-feature-media:focus-visible .home-feature-enter { + opacity: 1; + border-color: color-mix(in srgb, var(--scene-text) 62%, var(--scene-panel-border)); + background: var(--scene-panel-bg-strong); + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.3); + transform: translateY(-2px); +} + +.home-feature-media:hover .home-feature-enter::after, +.home-feature-media:focus-visible .home-feature-enter::after { + transform: translateX(2px) rotate(45deg); +} + +.home-feature-media.is-disabled:hover .home-feature-enter, +.home-feature-media.is-disabled:focus-visible .home-feature-enter { + border-color: color-mix(in srgb, var(--text-muted) 46%, var(--scene-panel-border)); + background: color-mix(in srgb, var(--scene-panel-bg) 72%, transparent); + opacity: 0.82; + box-shadow: none; + transform: none; +} + +.home-feature-copy { + display: grid; + align-content: center; + gap: 14px; + max-width: 610px; +} + +.home-feature-copy::before { + content: '0' counter(home-feature); + counter-increment: home-feature; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 650; + letter-spacing: 0.18em; +} + +.home-feature-copy h2 { + max-width: 590px; + margin: 0; + color: var(--text); + font-size: 2.8rem; + font-weight: 780; + line-height: 1.08; + letter-spacing: -0.01em; +} + +.home-feature-copy strong { + color: var(--text-soft); + font-size: 1.04rem; + font-weight: 760; +} + +.home-feature-copy p { + max-width: 590px; + margin: 0; + color: var(--text-muted); + line-height: 1.68; +} + +.home-feature-copy ul { + display: grid; + gap: 14px; + margin: 4px 0 0; + padding: 0; + list-style: none; +} + +.home-feature-copy li { + position: relative; + padding-left: 22px; + color: var(--text-soft); +} + +.home-feature-copy li::before { + content: ''; + position: absolute; + top: 0.82em; + left: 0; + width: 12px; + height: 1.5px; + background: color-mix(in srgb, var(--text) 52%, var(--border)); +} + +.home-feature-copy > .button { + justify-self: start; + margin-top: 12px; +} + +@media (prefers-reduced-motion: reduce) { + .home-page .site-header, + .home-stage::before, + .home-stage::after, + .home-canvas, + .home-hero, + .home-exit, + .home-space-entry, + .home-space-entry-dot { + animation: none; + transition: none; + } + + .home-try::before, + .home-try::after { + display: none; + } +} + +@media (max-width: 1100px) { + .home-hero strong { + max-width: 560px; + font-size: 1.1rem; + } + + .home-feature { + gap: 48px; + } + + .home-feature-copy h2 { + font-size: 2.42rem; + } +} + +@media (max-width: 900px) { + .home-hero { + padding: 104px 16px 64px; + } + + .home-space-entry { + right: calc(env(safe-area-inset-right, 0px) + 14px); + bottom: calc(env(safe-area-inset-bottom, 0px) + 14px); + min-height: 30px; + padding: 0 11px; + font-size: 0.74rem; + } + + .home-stage::after { + background: + linear-gradient(180deg, var(--bg), color-mix(in srgb, var(--bg) 72%, transparent)), + linear-gradient( + 90deg, + color-mix(in srgb, var(--bg) 94%, transparent), + color-mix(in srgb, var(--bg) 54%, transparent) + ); + } + + .home-canvas { + --home-canvas-preview-opacity: 0.48; + transform: perspective(1200px) scale(1.08); + } + + :root[data-theme='dark'] .home-canvas { + --home-canvas-preview-opacity: 0.6; + --home-canvas-enter-start-opacity: 0.6; + } + + .home-hero strong { + max-width: 560px; + font-size: 1.06rem; + line-height: 1.6; + } + + .home-actions { + margin-top: 32px; + } + + .home-specs { + grid-template-columns: 1fr; + gap: 12px; + max-width: 520px; + margin-top: 46px; + } + + .home-spec { + padding: 18px 18px 17px; + } + + .home-feature { + grid-template-columns: 1fr; + gap: 24px; + padding: 58px 16px; + } + + .home-feature:nth-child(even) { + grid-template-columns: 1fr; + } + + .home-feature:nth-child(even) .home-feature-media { + order: initial; + } + + .home-feature-media { + min-height: 280px; + } + + .home-feature-copy h2 { + font-size: 2.05rem; + } +} + +@media (max-width: 560px) { + .home-hero { + padding: 94px 14px 60px; + } + + .home-hero strong { + font-size: 1rem; + } + + .home-action-button { + width: 100%; + } + + .home-actions { + flex-direction: column; + align-items: stretch; + width: min(100%, 360px); + } + + .home-specs { + margin-top: 40px; + } + + .home-feature { + padding: 48px 14px; + } + + .home-feature-media { + min-height: 240px; + } + + .home-feature-copy h2 { + font-size: 1.9rem; + } + + .home-feature-copy > .button { + width: 100%; + } +} diff --git a/website/src/styles/playground.css b/website/src/styles/playground.css new file mode 100644 index 0000000..0277ed5 --- /dev/null +++ b/website/src/styles/playground.css @@ -0,0 +1,449 @@ +.playground-shell { + overflow: hidden; + background: var(--bg); +} + +.playground-workspace { + position: relative; + display: grid; + grid-template-columns: minmax(300px, var(--editor-width)) var(--workspace-splitter-size) minmax(320px, 1fr); + height: calc(100svh - var(--header-height)); + min-height: var(--workspace-min-height); + background: var(--bg); +} + +.playground-workspace[data-resizing='true'] { + cursor: col-resize; + user-select: none; +} + +.editor-panel, +.preview-panel { + position: relative; + min-width: 0; + min-height: var(--workspace-min-height); + overflow: hidden; +} + +.editor-panel { + border-right: 1px solid var(--border); + background: var(--surface); +} + +.preview-panel { + background: var(--scene-surface); +} + +.editor-panel .monaco-editor, +.editor-panel .monaco-editor-background, +.editor-panel .inputarea.ime-input { + font-family: var(--font-mono); +} + +.editor-loading { + position: absolute; + z-index: 2; + inset: 0; + display: grid; + place-items: center; + color: var(--text-muted); + background: var(--surface); +} + +.playground-editor-dock { + position: absolute; + right: 16px; + bottom: 16px; + z-index: 6; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + max-width: calc(100% - 32px); + padding: 4px; + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + background: var(--glass-bg); + box-shadow: var(--shadow-glass); + backdrop-filter: blur(14px); +} + +.dock-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + box-shadow: none; + color: var(--text-muted); + cursor: pointer; + font-size: 0.9rem; + font-weight: 680; + padding: 0 12px; + text-align: center; + white-space: nowrap; +} + +.dock-button:hover, +.dock-button[aria-expanded='true'] { + background: var(--surface-muted); + color: var(--text); +} + +.dock-button.run { + gap: 8px; + min-width: 74px; + border-color: var(--primary-strong); + background: var(--primary-strong); + color: var(--primary-contrast); + padding: 0 13px 0 10px; + box-shadow: var(--control-primary-shadow); +} + +.dock-button.run::before { + content: ''; + flex: 0 0 auto; + width: 7px; + height: 7px; + border-radius: var(--radius-pill); + background: currentColor; + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 18%, transparent); +} + +.dock-button.run:hover { + border-color: var(--primary-hover); + background: var(--primary-hover); +} + +.dock-button.run[data-state='loading'] { + border-color: color-mix(in srgb, var(--primary-strong) 72%, var(--border)); + background: color-mix(in srgb, var(--primary-strong) 94%, var(--surface)); +} + +.dock-button.run[data-state='loading']::before { + animation: playground-status-pulse 1s ease-in-out infinite; +} + +.dock-button.run[data-state='error'] { + border-color: var(--danger); + background: var(--danger); + color: var(--color-white); + box-shadow: 0 10px 24px color-mix(in srgb, var(--danger) 20%, transparent); +} + +.dock-button.run[data-state='error']:hover { + background: var(--danger-strong); +} + +@keyframes playground-status-pulse { + 0%, + 100% { + opacity: 0.62; + transform: scale(0.86); + } + + 50% { + opacity: 1; + transform: scale(1); + } +} + +.playground-preset-menu { + position: relative; + min-width: 0; +} + +.preset-trigger { + display: grid; + grid-template-columns: minmax(72px, 130px) auto; + align-items: center; + gap: 7px; + max-width: 168px; + min-height: 34px; + padding: 0 10px; +} + +.preset-trigger strong { + overflow: hidden; + color: var(--text); + font-size: 0.9rem; + font-weight: 760; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preset-trigger::after { + content: ''; + width: 7px; + height: 7px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + margin-left: 2px; + transform: translateY(-2px) rotate(45deg); +} + +.preset-popover { + position: absolute; + right: 0; + bottom: calc(100% + 10px); + display: grid; + width: min(380px, calc(100vw - 32px)); + max-height: min(420px, calc(100vh - 160px)); + overflow: auto; + padding: 6px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); +} + +.preset-popover[hidden] { + display: none; +} + +.preset-option { + display: grid; + grid-template-columns: 10px minmax(0, 1fr); + align-items: stretch; + gap: 10px; + width: 100%; + min-height: 78px; + padding: 10px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + box-shadow: none; + color: var(--text); + cursor: pointer; + font: inherit; + text-align: left; +} + +.preset-option:hover, +.preset-option:focus-visible, +.preset-option[data-active='true'] { + background: var(--surface-muted); +} + +.preset-option[data-active='true'] { + border-color: color-mix(in srgb, var(--preset-accent) 44%, var(--border)); +} + +.preset-option-marker { + border-radius: var(--radius-pill); + background: var(--preset-accent); +} + +.preset-option-content { + display: grid; + gap: 3px; + min-width: 0; +} + +.preset-option-content strong, +.preset-option-content span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preset-option-content strong { + color: var(--text); + font-size: 0.95rem; + font-weight: 780; +} + +.preset-option-content span { + color: var(--text-muted); + font-size: 0.86rem; + line-height: 1.35; +} + +.preset-option-content .preset-option-tags { + color: var(--text-soft); + font-family: var(--font-mono); + font-size: 0.75rem; + text-transform: uppercase; +} + +.preview-panel canvas { + display: block; + width: 100%; + height: 100%; + min-height: 0; + background: var(--scene-surface); +} + +.preview-status { + position: absolute; + top: 50%; + left: 50%; + z-index: 7; + max-width: min(360px, calc(100% - 40px)); + padding: 9px 12px; + border: 1px solid color-mix(in srgb, var(--primary) 38%, var(--scene-panel-border)); + border-radius: var(--radius-sm); + background: var(--scene-panel-bg); + color: var(--scene-text); + font-size: 0.82rem; + font-weight: 720; + line-height: 1.35; + text-align: center; + box-shadow: var(--shadow-scene); + transform: translate(-50%, -50%); + backdrop-filter: blur(12px); +} + +.preview-status[hidden] { + display: none; +} + +.inspector-window { + position: absolute; + z-index: 8; + top: 12px; + left: 12px; + width: min(248px, calc(100% - 24px)); + max-width: calc(100% - 24px); + --tool-panel-label-width: 84px; +} + +.inspector-window .tp-grlv_g { + min-height: 48px; + shape-rendering: geometricPrecision; +} + +.inspector-window .tp-grlv_g polyline { + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.config-panel-window { + position: absolute; + z-index: 8; + top: 12px; + right: 12px; + width: min(300px, calc(100% - 24px)); + max-width: calc(100% - 24px); + max-height: calc(100% - 24px); + overflow: auto; +} + +@media (max-width: 980px) { + .playground-page { + overflow: auto; + } + + .playground-shell { + margin: 14px 12px 36px; + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-soft); + } + + .playground-workspace { + grid-template-columns: 1fr; + height: auto; + min-height: auto; + } + + .playground-workspace[data-pane-collapsed='true'] { + grid-template-columns: 1fr; + } + + .workspace-splitter.workspace-resizer { + display: none; + } + + .playground-workspace[data-pane-collapsed='true'] .editor-panel { + visibility: visible; + overflow: visible; + opacity: 1; + pointer-events: auto; + } + + .editor-panel, + .preview-panel { + min-height: 420px; + } + + .editor-panel { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .playground-editor-dock { + right: 10px; + bottom: 10px; + left: 10px; + flex-wrap: nowrap; + justify-content: stretch; + max-width: none; + overflow-x: auto; + scrollbar-width: none; + } + + .playground-editor-dock::-webkit-scrollbar { + display: none; + } + + .playground-preset-menu { + flex: 1 1 auto; + } + + .preset-trigger { + grid-template-columns: minmax(0, 1fr) auto; + width: 100%; + max-width: none; + } + + .dock-button:not(.preset-trigger) { + flex: 0 0 auto; + padding: 0 9px; + } + + .preset-popover { + position: fixed; + right: 22px; + bottom: 128px; + left: 22px; + width: auto; + max-height: min(56vh, 420px); + } + + .config-panel-window { + top: auto; + bottom: 12px; + max-height: min(46vh, 360px); + } +} + +:root[data-workspace-fullscreen='true'] .playground-shell { + margin: 0; + border: 0; + border-radius: 0; + box-shadow: none; +} + +:root[data-workspace-fullscreen='true'] .playground-workspace { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + height: 100dvh; + min-height: 100dvh; +} + +:root[data-workspace-fullscreen='true'] .editor-panel, +:root[data-workspace-fullscreen='true'] .workspace-splitter.workspace-resizer, +:root[data-workspace-fullscreen='true'] .inspector-window, +:root[data-workspace-fullscreen='true'] .config-panel-window, +:root[data-workspace-fullscreen='true'] .viewer-interaction-guide { + display: none; +} + +:root[data-workspace-fullscreen='true'] .preview-panel { + min-height: 100dvh; +} diff --git a/website/src/styles/site.css b/website/src/styles/site.css new file mode 100644 index 0000000..457430e --- /dev/null +++ b/website/src/styles/site.css @@ -0,0 +1,538 @@ +.site-header { + position: sticky; + top: 0; + z-index: 30; + display: flex; + align-items: center; + gap: 16px; + min-height: var(--header-height); + padding: 0 clamp(16px, 2.4vw, 36px); + border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + background: color-mix(in srgb, var(--surface) 86%, transparent); + backdrop-filter: blur(18px) saturate(1.4); +} + +.brand { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + color: var(--text); + line-height: 0; +} + +.brand-logo { + display: block; + width: clamp(142px, 16vw, 164px); + height: 24px; +} + +:root[data-theme='dark'] .brand-logo { + filter: invert(1) hue-rotate(180deg) brightness(1.18); +} + +.site-menu-toggle, +.site-drawer-close { + display: none; +} + +.site-mobile-drawer-shell { + display: none; +} + +.site-nav { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + height: 100%; +} + +.site-nav a { + position: relative; + display: inline-flex; + align-items: center; + min-height: 34px; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 0.92rem; + font-weight: 650; + padding: 0 12px; + white-space: nowrap; + transition: + background 160ms ease, + color 160ms ease; +} + +.site-nav a.is-active::after { + content: ''; + position: absolute; + right: 12px; + bottom: 5px; + left: 12px; + height: 2px; + border-radius: var(--radius-pill); + background: var(--text); +} + +.site-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.language-switch { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 38px; + min-height: 34px; + border-radius: var(--radius-sm); + padding: 0 8px; + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 700; + text-align: center; + transition: + background 160ms ease, + color 160ms ease; +} + +.theme-toggle, +.github-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + min-width: 36px; + min-height: 36px; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + box-shadow: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1rem; + transition: + background 160ms ease, + color 160ms ease; +} + +.site-nav a:hover, +.site-nav a.is-active, +.language-switch:hover, +.theme-toggle:hover, +.github-link:hover { + background: var(--surface-muted); + color: var(--text); +} + +.icon-github { + display: block; + fill: currentColor; +} + +:root[data-site-menu-open='true'] body { + overflow: hidden; +} + +.workspace-page { + overflow: hidden; +} + +.workspace-grid[data-pane-collapsed='true'] { + grid-template-columns: 0 var(--workspace-splitter-expanded-size) minmax(0, 1fr); +} + +.workspace-grid[data-pane-collapsed='true'] > .workspace-primary-pane { + visibility: hidden; + overflow: hidden; + border-right-color: transparent; + opacity: 0; + pointer-events: none; +} + +.workspace-grid[data-pane-collapsed='true'] > .workspace-resizer { + cursor: e-resize; + background: transparent; +} + +.workspace-grid[data-pane-collapsed='true'] > .workspace-resizer::before { + inset: 12px 5px; + opacity: 0.72; +} + +.tool-panel-window { + color: #f8fafc; + scrollbar-width: none; + backdrop-filter: blur(16px); + --tp-base-background-color: rgba(8, 13, 22, 0.88); + --tp-base-border-radius: var(--radius); + --tp-base-font-family: var(--font-mono); + --tp-base-shadow-color: rgba(0, 0, 0, 0.3); + --tp-blade-border-radius: 4px; + --tp-blade-horizontal-padding: 3px; + --tp-blade-value-width: 118px; + --tp-button-background-color: rgba(226, 232, 240, 0.82); + --tp-button-background-color-active: rgba(248, 250, 252, 0.95); + --tp-button-background-color-focus: rgba(226, 232, 240, 0.9); + --tp-button-background-color-hover: rgba(241, 245, 249, 0.95); + --tp-button-foreground-color: var(--color-ink-900); + --tp-container-background-color: rgba(148, 163, 184, 0.11); + --tp-container-background-color-active: rgba(148, 163, 184, 0.24); + --tp-container-background-color-focus: rgba(148, 163, 184, 0.2); + --tp-container-background-color-hover: rgba(148, 163, 184, 0.16); + --tp-container-foreground-color: var(--scene-text); + --tp-container-horizontal-padding: 3px; + --tp-container-unit-size: 18px; + --tp-container-vertical-padding: 3px; + --tp-input-background-color: rgba(15, 23, 42, 0.64); + --tp-input-background-color-active: rgba(30, 41, 59, 0.86); + --tp-input-background-color-focus: rgba(30, 41, 59, 0.78); + --tp-input-background-color-hover: rgba(30, 41, 59, 0.72); + --tp-input-foreground-color: #f8fafc; + --tp-label-foreground-color: rgba(226, 232, 240, 0.72); + --tp-monitor-background-color: rgba(2, 6, 23, 0.52); + --tp-monitor-foreground-color: #a7f3d0; + --tp-groove-foreground-color: rgba(226, 232, 240, 0.12); +} + +.tool-panel-window::-webkit-scrollbar { + width: 0; + height: 0; +} + +.tool-panel-window[hidden] { + display: none; +} + +.tool-panel-window > .tp-rotv { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: 0 22px 52px rgba(0, 0, 0, 0.3); +} + +.tool-panel-window .tp-rotv { + font-size: 10px; +} + +.tool-panel-window .tp-rotv_b { + min-height: 26px; + letter-spacing: 0; +} + +.tool-panel-window .tp-lblv { + min-height: 23px; +} + +.tool-panel-window .tp-lblv_l { + flex: 0 0 var(--tool-panel-label-width, 88px); + min-width: 0; + padding-right: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tool-panel-window .tp-lblv_v { + flex: 1 1 auto; + width: auto; + min-width: 0; +} + +.workspace-resizer { + position: relative; + z-index: 4; + color: var(--text-muted); + cursor: col-resize; + background: var(--workspace-splitter-bg); +} + +.workspace-resizer::before { + content: ''; + position: absolute; + inset: 0 3px; + border-radius: var(--radius-pill); + background: var(--workspace-splitter-handle); + opacity: 0; + transition: opacity 160ms ease; +} + +.workspace-resizer:is(:hover, :focus-visible)::before, +[data-resizing='true'] > .workspace-resizer::before { + opacity: 1; +} + +@media (max-width: 900px) { + .site-header { + flex-wrap: nowrap; + min-height: var(--header-height-compact); + gap: 8px; + padding: 8px 10px; + } + + .site-menu-toggle, + .site-drawer-close { + position: relative; + display: inline-grid; + place-items: center; + flex: 0 0 auto; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; + } + + .site-menu-toggle { + width: 36px; + height: 36px; + transition: + background 160ms ease, + color 160ms ease, + border-color 160ms ease; + } + + .site-menu-toggle:hover, + .site-menu-toggle[aria-expanded='true'], + .site-drawer-close:hover { + border-color: var(--border-subtle); + background: var(--surface-muted); + color: var(--text); + } + + .site-menu-icon, + .site-menu-icon::before, + .site-menu-icon::after { + display: block; + width: 16px; + height: 2px; + border-radius: var(--radius-pill); + background: currentColor; + transition: + transform 160ms ease, + opacity 160ms ease; + } + + .site-menu-icon { + position: relative; + } + + .site-menu-icon::before, + .site-menu-icon::after { + position: absolute; + left: 0; + content: ''; + } + + .site-menu-icon::before { + top: -5px; + } + + .site-menu-icon::after { + top: 5px; + } + + .site-menu-toggle[aria-expanded='true'] .site-menu-icon { + background: transparent; + } + + .site-menu-toggle[aria-expanded='true'] .site-menu-icon::before { + transform: translateY(5px) rotate(45deg); + } + + .site-menu-toggle[aria-expanded='true'] .site-menu-icon::after { + transform: translateY(-5px) rotate(-45deg); + } + + .brand-logo { + width: 136px; + height: 20px; + } + + .site-nav { + display: none; + } + + .site-actions { + margin-left: auto; + gap: 4px; + } + + .language-switch { + min-width: 34px; + min-height: 32px; + padding: 0 6px; + } + + .theme-toggle, + .github-link { + width: 32px; + min-width: 32px; + min-height: 32px; + } + + .site-mobile-drawer-shell { + position: fixed; + inset: 0; + z-index: 90; + display: block; + visibility: hidden; + pointer-events: none; + } + + .site-mobile-drawer-shell[aria-hidden='false'] { + visibility: visible; + pointer-events: auto; + } + + .site-drawer-backdrop { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + background: color-mix(in srgb, var(--color-black) 34%, transparent); + opacity: 0; + cursor: default; + transition: opacity 180ms ease; + } + + .site-mobile-drawer-shell[aria-hidden='false'] .site-drawer-backdrop { + opacity: 1; + } + + .site-mobile-drawer { + position: absolute; + top: 0; + bottom: 0; + left: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: min(86vw, 360px); + min-width: 280px; + max-width: calc(100vw - 42px); + overflow: auto; + border-right: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 96%, transparent); + box-shadow: var(--shadow); + scrollbar-width: thin; + transform: translateX(-100%); + transition: transform 200ms ease; + backdrop-filter: blur(18px); + } + + .site-mobile-drawer-shell[aria-hidden='false'] .site-mobile-drawer { + transform: translateX(0); + } + + .site-drawer-header { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: var(--header-height-compact); + padding: 8px 12px; + border-bottom: 1px solid var(--border-subtle); + background: var(--surface); + } + + .site-drawer-brand { + display: inline-flex; + align-items: center; + min-width: 0; + line-height: 0; + } + + .site-drawer-close { + width: 34px; + height: 34px; + } + + .site-drawer-close-icon { + position: relative; + display: block; + width: 14px; + height: 14px; + } + + .site-drawer-close-icon::before, + .site-drawer-close-icon::after { + position: absolute; + top: 6px; + left: 0; + width: 14px; + height: 2px; + border-radius: var(--radius-pill); + background: currentColor; + content: ''; + } + + .site-drawer-close-icon::before { + transform: rotate(45deg); + } + + .site-drawer-close-icon::after { + transform: rotate(-45deg); + } + + .site-drawer-nav { + display: grid; + align-content: start; + gap: 4px; + padding: 10px; + } + + .site-drawer-nav a { + display: flex; + align-items: center; + min-height: 42px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 0.95rem; + font-weight: 720; + padding: 0 12px; + transition: + background 160ms ease, + border-color 160ms ease, + color 160ms ease; + } + + .site-drawer-nav a:hover, + .site-drawer-nav a.is-active { + border-color: var(--border-subtle); + background: var(--surface-muted); + color: var(--text); + } + + .site-drawer-nav a.is-active { + box-shadow: inset 3px 0 0 var(--primary); + } + + .site-drawer-nav-context { + min-width: 0; + margin: 2px 0 10px 12px; + border-left: 1px solid var(--border-subtle); + padding: 8px 0 2px 10px; + } +} + +@media (max-width: 380px) { + .brand-logo { + width: 124px; + } +} + +:root[data-workspace-fullscreen='true'] body:is(.example-viewer-page, .playground-page, .viewer-page) { + overflow: hidden; + background: var(--scene-bg); +} + +:root[data-workspace-fullscreen='true'] body:is(.example-viewer-page, .playground-page, .viewer-page) .site-header { + display: none; +} diff --git a/website/src/styles/theme.css b/website/src/styles/theme.css new file mode 100644 index 0000000..bf25c23 --- /dev/null +++ b/website/src/styles/theme.css @@ -0,0 +1,117 @@ +:root { + color-scheme: light; + --color-white: #ffffff; + --color-black: #000000; + --color-ink-950: #020617; + --color-ink-900: #0f172a; + --color-ink-800: #1e293b; + --color-ink-700: #334155; + --color-ink-500: #64748b; + --color-ink-300: #cbd5e1; + --color-red-600: #dc2626; + --color-red-400: #f87171; + --bg: #fafafa; + --surface: var(--color-white); + --surface-muted: #f4f4f5; + --surface-subtle: #fcfcfc; + --text: #0a0a0a; + --text-soft: #3f3f46; + --text-muted: #6f6f78; + --border: #e4e4e7; + --border-subtle: #ededf0; + --primary: #18181b; + --primary-strong: #09090b; + --primary-soft: #f4f4f5; + --primary-contrast: var(--color-white); + --primary-hover: #2c2c30; + --accent: #52525b; + --focus: #18181b; + --danger: var(--color-red-600); + --danger-strong: color-mix(in srgb, var(--danger) 88%, var(--color-black)); + --danger-on-scene: #fecaca; + --code-bg: #0f0f10; + --code-text: #ececee; + --code-selection-bg: #2563eb; + --code-selection-text: #ffffff; + --code-type: #0b5cad; + --code-param: #99461a; + --code-func: #6e40c9; + --shadow: 0 16px 40px rgba(0, 0, 0, 0.08); + --shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 22px rgba(0, 0, 0, 0.03); + --shadow-glass: 0 10px 24px rgba(0, 0, 0, 0.08); + --shadow-scene: 0 18px 48px rgba(0, 0, 0, 0.24); + --radius: 8px; + --radius-sm: 6px; + --radius-pill: 999px; + --content: 1180px; + --header-height: 56px; + --header-height-compact: 52px; + --workspace-min-height: 620px; + --workspace-splitter-size: 9px; + --workspace-splitter-expanded-size: 12px; + --workspace-splitter-bg: color-mix(in srgb, var(--border) 68%, transparent); + --workspace-splitter-handle: color-mix(in srgb, var(--primary) 45%, var(--text-muted)); + --control-height: 38px; + --control-height-sm: 32px; + --control-border: var(--border); + --control-bg: var(--surface); + --control-hover-border: color-mix(in srgb, var(--primary) 56%, var(--border)); + --control-primary-shadow: 0 8px 20px rgba(0, 0, 0, 0.14); + --glass-bg: color-mix(in srgb, var(--surface) 84%, transparent); + --glass-border: color-mix(in srgb, var(--border) 72%, transparent); + --page-bg: linear-gradient(180deg, var(--surface), transparent 320px), var(--bg); + --scene-bg: var(--color-black); + --scene-surface: linear-gradient(180deg, #131316, #09090b), #0e0e10; + --scene-text: rgba(255, 255, 255, 0.86); + --scene-text-muted: rgba(228, 228, 231, 0.66); + --scene-panel-bg: rgba(9, 9, 11, 0.74); + --scene-panel-bg-soft: rgba(9, 9, 11, 0.72); + --scene-panel-bg-strong: rgba(24, 24, 27, 0.74); + --scene-panel-border: rgba(255, 255, 255, 0.14); + --scene-panel-border-soft: rgba(255, 255, 255, 0.12); + --scene-grid-line: rgba(255, 255, 255, 0.08); + --scene-overlay-strong: rgba(9, 9, 11, 0.6); + --scene-text-shadow: rgba(0, 0, 0, 0.86); + --scene-control-bg: rgba(255, 255, 255, 0.96); + --scene-control-border: rgba(255, 255, 255, 0.8); + --scene-control-indicator: rgba(255, 255, 255, 0.72); + --scene-control-shadow: 0 14px 30px rgba(0, 0, 0, 0.24), 0 1px 2px rgba(255, 255, 255, 0.7) inset; + --scene-control-shadow-hover: 0 18px 38px rgba(0, 0, 0, 0.28), 0 1px 2px rgba(255, 255, 255, 0.78) inset; + --font-sans: Aptos, 'Segoe UI Variable', 'Segoe UI', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; +} + +:root[data-theme='dark'] { + color-scheme: dark; + --bg: #050506; + --surface: #0e0e10; + --surface-muted: #18181b; + --surface-subtle: #0a0a0b; + --text: #e6e6ea; + --text-soft: #b9b9c1; + --text-muted: #8b8b94; + --border: #242427; + --border-subtle: #1a1a1d; + --primary: #e2e2e6; + --primary-strong: #ebebee; + --primary-soft: rgba(230, 230, 234, 0.08); + --primary-contrast: #101012; + --primary-hover: #d2d2d8; + --accent: #9d9da6; + --focus: #e2e2e6; + --danger: var(--color-red-400); + --danger-strong: color-mix(in srgb, var(--danger) 82%, var(--color-black)); + --code-bg: #0a0a0b; + --code-text: #dcdce1; + --code-selection-bg: #2563eb; + --code-selection-text: #ffffff; + --code-type: #7fb0ea; + --code-param: #d9a26a; + --code-func: #b79ce8; + --shadow: 0 20px 48px rgba(0, 0, 0, 0.42); + --shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 30px rgba(0, 0, 0, 0.22); + --shadow-glass: 0 10px 24px rgba(0, 0, 0, 0.3); + --control-primary-shadow: 0 8px 22px rgba(0, 0, 0, 0.4); + --glass-bg: color-mix(in srgb, var(--surface) 86%, transparent); + --page-bg: linear-gradient(180deg, var(--surface-subtle), transparent 320px), var(--bg); +} diff --git a/website/src/styles/viewer.css b/website/src/styles/viewer.css new file mode 100644 index 0000000..e67d220 --- /dev/null +++ b/website/src/styles/viewer.css @@ -0,0 +1,964 @@ +.viewer-shell { + --viewer-panel-gap: 14px; + --viewer-left-width: clamp(236px, 16vw, 276px); + --viewer-right-width: clamp(232px, 18vw, 288px); + position: relative; + isolation: isolate; + height: calc(100dvh - var(--header-height)); + min-height: var(--workspace-min-height); + overflow: hidden; + background: var(--scene-bg); +} + +.viewer-rail { + position: absolute; + top: var(--viewer-panel-gap); + bottom: var(--viewer-panel-gap); + z-index: 10; + min-width: 0; + min-height: 0; + overflow: auto; + border: 1px solid var(--glass-border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--surface) 94%, transparent); + box-shadow: var(--shadow-glass); + scrollbar-width: thin; + backdrop-filter: blur(16px); +} + +.viewer-rail::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.viewer-rail::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--text-muted) 34%, transparent); + background-clip: padding-box; +} + +.viewer-left-rail { + left: var(--viewer-panel-gap); + bottom: auto; + width: var(--viewer-left-width); + max-height: calc(100% - (var(--viewer-panel-gap) * 2)); + transition: + opacity 180ms ease, + transform 180ms ease; +} + +.viewer-right-rail { + right: var(--viewer-panel-gap); + bottom: auto; + width: var(--viewer-right-width); + max-height: calc(100% - (var(--viewer-panel-gap) * 2)); + overflow-x: hidden; + transition: + opacity 180ms ease, + transform 180ms ease; +} + +:is( + .viewer-shell[data-left-collapsed='true'], + :root[data-viewer-left-collapsed='true'] .viewer-shell[data-left-collapsed='auto'] + ) + .viewer-left-rail { + opacity: 0; + pointer-events: none; + transform: translateX(calc(-1 * (var(--viewer-left-width) + (var(--viewer-panel-gap) * 2)))); +} + +:is( + .viewer-shell[data-right-collapsed='true'], + :root[data-viewer-right-collapsed='true'] .viewer-shell[data-right-collapsed='auto'] + ) + .viewer-right-rail { + opacity: 0; + pointer-events: none; + transform: translateX(calc(var(--viewer-right-width) + (var(--viewer-panel-gap) * 2))); +} + +.viewer-left-toggle, +.viewer-right-toggle { + position: absolute; + top: var(--viewer-panel-gap); + z-index: 15; + display: inline-grid; + place-items: center; + width: 36px; + height: 36px; + padding: 0; + border: 1px solid var(--scene-panel-border); + border-radius: var(--radius-sm); + background: var(--scene-panel-bg-soft); + color: var(--scene-text); + opacity: 0; + pointer-events: none; + box-shadow: var(--shadow-scene); + cursor: pointer; + backdrop-filter: blur(14px); + transition: + background 160ms ease, + border-color 160ms ease, + color 160ms ease, + opacity 160ms ease, + transform 180ms ease, + left 180ms ease, + right 180ms ease; +} + +.viewer-left-toggle { + left: var(--viewer-panel-gap); +} + +.viewer-right-toggle { + right: var(--viewer-panel-gap); +} + +.viewer-left-toggle:hover, +.viewer-right-toggle:hover { + border-color: color-mix(in srgb, var(--primary) 48%, var(--scene-panel-border)); + background: var(--scene-panel-bg-strong); + color: var(--color-white); +} + +:is( + .viewer-shell[data-left-collapsed='true'], + :root[data-viewer-left-collapsed='true'] .viewer-shell[data-left-collapsed='auto'] + ) + .viewer-left-toggle { + opacity: 1; + pointer-events: auto; +} + +:is( + .viewer-shell[data-right-collapsed='true'], + :root[data-viewer-right-collapsed='true'] .viewer-shell[data-right-collapsed='auto'] + ) + .viewer-right-toggle { + opacity: 1; + pointer-events: auto; +} + +.viewer-collapse-icon { + width: 9px; + height: 9px; + border-top: 2px solid currentColor; + border-right: 2px solid currentColor; + transform: rotate(-135deg); +} + +:is( + .viewer-shell[data-left-collapsed='true'], + :root[data-viewer-left-collapsed='true'] .viewer-shell[data-left-collapsed='auto'] + ) + .viewer-collapse-icon { + transform: rotate(45deg); +} + +.viewer-settings-icon { + position: relative; + display: block; + width: 16px; + height: 14px; +} + +.viewer-settings-icon::before { + content: ''; + position: absolute; + inset: 1px 0; + background: + linear-gradient(currentColor 0 0) 0 1px / 16px 2px no-repeat, + linear-gradient(currentColor 0 0) 0 6px / 16px 2px no-repeat, + linear-gradient(currentColor 0 0) 0 11px / 16px 2px no-repeat; +} + +.viewer-settings-icon::after { + content: ''; + position: absolute; + top: 0; + left: 3px; + width: 4px; + height: 4px; + border-radius: var(--radius-pill); + background: currentColor; + box-shadow: + 8px 5px 0 currentColor, + 3px 10px 0 currentColor; +} + +:is( + .viewer-shell[data-left-collapsed='true'], + :root[data-viewer-left-collapsed='true'] .viewer-shell[data-left-collapsed='auto'] + ) + .viewer-status { + left: 50%; + transform: translateX(-50%); +} + +.viewer-left-header { + display: flex; + justify-content: flex-end; + min-height: 38px; + padding: 5px 7px; + border-bottom: 1px solid var(--border-subtle); +} + +.viewer-left-close, +.viewer-right-close { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.viewer-left-close:hover { + border-color: var(--border); + background: var(--surface-subtle); + color: var(--text); +} + +.viewer-right-close { + position: absolute; + top: 6px; + right: 6px; + z-index: 2; + width: 24px; + height: 24px; + border-color: var(--scene-panel-border); + background: var(--scene-panel-bg-soft); + color: var(--scene-text); + box-shadow: var(--shadow-scene); +} + +.viewer-right-close:hover { + border-color: color-mix(in srgb, var(--primary) 48%, var(--scene-panel-border)); + background: var(--scene-panel-bg-strong); + color: var(--color-white); +} + +.viewer-close-icon { + position: relative; + display: block; + width: 12px; + height: 12px; +} + +.viewer-close-icon::before, +.viewer-close-icon::after { + content: ''; + position: absolute; + top: 5px; + left: 0; + width: 12px; + height: 2px; + border-radius: var(--radius-pill); + background: currentColor; +} + +.viewer-close-icon::before { + transform: rotate(45deg); +} + +.viewer-close-icon::after { + transform: rotate(-45deg); +} + +.viewer-panel-section { + display: grid; + gap: 12px; + padding: 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.viewer-panel-section:last-child { + border-bottom: 0; +} + +.viewer-section-header h2 { + margin: 0; + color: var(--text); + font-size: 0.78rem; + font-weight: 820; + letter-spacing: 0; + line-height: 1.2; + text-transform: uppercase; +} + +.viewer-camera-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.viewer-field { + display: grid; + gap: 7px; + min-width: 0; +} + +.viewer-field label { + color: var(--text-muted); + font-size: 0.76rem; + font-weight: 760; +} + +.viewer-field input, +.viewer-field select, +.viewer-field textarea { + width: 100%; + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-subtle); + color: var(--text); + font: inherit; + font-size: 0.88rem; +} + +.viewer-field input, +.viewer-field select { + min-height: 34px; + padding: 0 9px; +} + +.viewer-field textarea { + resize: vertical; + min-height: 62px; + max-height: 130px; + padding: 9px; + line-height: 1.4; +} + +.viewer-field input:focus-visible, +.viewer-field select:focus-visible, +.viewer-field textarea:focus-visible, +.viewer-file-picker:focus-within, +.viewer-panel-section button:focus-visible, +.viewer-left-close:focus-visible, +.viewer-right-close:focus-visible, +.viewer-left-toggle:focus-visible, +.viewer-right-toggle:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.viewer-field-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(86px, 0.56fr); + gap: 8px; + min-width: 0; +} + +.viewer-check-field { + position: relative; + display: inline-flex; + align-items: center; + gap: 5px; + justify-self: start; + min-width: 0; + min-height: 18px; + padding: 0; + cursor: pointer; +} + +.viewer-check-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.viewer-camera-header .viewer-check-label { + color: var(--text-muted); + font-size: 0.76rem; + font-weight: 760; + line-height: 1; +} + +.viewer-check-field input { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + opacity: 0; + pointer-events: none; +} + +.viewer-check-field:hover .viewer-toggle-switch { + border-color: color-mix(in srgb, var(--border) 58%, var(--text)); +} + +.viewer-toggle-switch { + position: relative; + flex: 0 0 auto; + width: 26px; + height: 16px; + border: 1px solid color-mix(in srgb, var(--border) 82%, var(--text-muted)); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--surface-muted) 84%, var(--scene-bg)); + box-shadow: inset 0 1px 2px rgba(2, 6, 23, 0.18); + transition: + border-color 140ms ease, + background 140ms ease; +} + +.viewer-toggle-switch::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text-muted); + box-shadow: 0 1px 2px rgba(2, 6, 23, 0.32); + transition: + background 140ms ease, + transform 140ms ease; +} + +.viewer-check-field input:checked + .viewer-toggle-switch { + border-color: color-mix(in srgb, var(--primary) 78%, var(--primary-strong)); + background: color-mix(in srgb, var(--primary) 62%, var(--scene-panel-bg-strong)); +} + +.viewer-check-field input:checked + .viewer-toggle-switch::before { + background: var(--primary-contrast); + transform: translateX(10px); +} + +.viewer-check-field:focus-within .viewer-toggle-switch { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.viewer-button-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.viewer-import-section { + gap: 10px; +} + +.viewer-url-form { + gap: 8px; +} + +.viewer-url-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; +} + +.viewer-url-actions button { + min-width: 0; + padding-right: 8px; + padding-left: 8px; + overflow-wrap: anywhere; +} + +.viewer-camera-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.viewer-camera-actions button { + min-height: 32px; + padding: 0 7px; + font-size: 0.78rem; +} + +.viewer-camera-actions button.secondary { + grid-column: 1 / -1; +} + +.viewer-panel-section button:not(.viewer-file-delete), +.viewer-file-picker { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + border: 1px solid var(--primary-strong); + border-radius: var(--radius-sm); + background: var(--primary-strong); + color: var(--primary-contrast); + cursor: pointer; + font-size: 0.84rem; + font-weight: 760; + line-height: 1.2; + padding: 0 11px; + text-align: center; +} + +.viewer-panel-section button:not(.viewer-file-delete):hover { + background: var(--primary-hover); + border-color: var(--primary-hover); +} + +.viewer-panel-section button.secondary { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text); +} + +.viewer-panel-section button.secondary:hover { + border-color: color-mix(in srgb, var(--border) 76%, var(--text)); + background: color-mix(in srgb, var(--surface-muted) 82%, var(--text)); +} + +.viewer-file-picker { + position: relative; + display: grid; + justify-items: center; + align-content: center; + gap: 4px; + width: 100%; + min-height: 72px; + padding: 11px; + border-style: dashed; + border-color: color-mix(in srgb, var(--primary-strong) 38%, var(--border)); + background: color-mix(in srgb, var(--surface-muted) 58%, transparent); + color: var(--text); + overflow: hidden; +} + +.viewer-file-picker:hover { + border-color: color-mix(in srgb, var(--primary-strong) 66%, var(--border)); + background: color-mix(in srgb, var(--primary-soft) 34%, var(--surface)); +} + +.viewer-file-picker input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.viewer-file-picker span { + min-width: 0; + pointer-events: none; +} + +.viewer-file-picker-copy { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.viewer-file-picker-meta { + max-width: 100%; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.66rem; + font-weight: 650; + line-height: 1.25; + overflow-wrap: anywhere; + text-align: center; +} + +.viewer-file-list { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.viewer-empty-file, +.viewer-file-item { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--surface-subtle); +} + +.viewer-empty-file { + color: var(--text-muted); + font-size: 0.86rem; + padding: 11px; +} + +.viewer-file-item { + display: block; + min-width: 0; + min-height: 42px; + padding: 9px 10px; +} + +.viewer-file-item-header { + display: grid; + grid-template-columns: minmax(0, 1fr) 10px 24px; + align-items: center; + gap: 8px; + min-width: 0; + min-height: 24px; +} + +.viewer-file-info { + display: flex; + align-items: baseline; + gap: 7px; + min-width: 0; +} + +.viewer-file-info strong, +.viewer-file-info span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.viewer-file-info strong { + flex: 1 1 auto; + color: var(--text); + font-size: 0.88rem; +} + +.viewer-file-info span { + flex: 0 0 auto; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.72rem; +} + +.viewer-file-state { + display: block; + width: 8px; + height: 8px; + border-radius: var(--radius-pill); + background: var(--accent); +} + +.viewer-file-state[data-state='ready'] { + background: var(--primary-strong); +} + +.viewer-file-state[data-state='error'] { + background: var(--danger); +} + +.viewer-file-delete { + display: inline-grid; + place-items: center; + width: 24px; + height: 24px; + min-height: 24px; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--danger); + cursor: pointer; +} + +.viewer-file-delete:hover { + color: color-mix(in srgb, var(--danger) 82%, var(--text)); +} + +.viewer-delete-icon { + display: block; + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.viewer-file-item[data-state='error'] { + border-color: color-mix(in srgb, var(--danger) 42%, var(--border)); +} + +.viewer-stage { + position: absolute; + inset: 0; + z-index: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--scene-bg); +} + +.viewer-render-surface { + position: absolute; + inset: 0; + overflow: hidden; + touch-action: none; +} + +.viewer-render-surface canvas { + display: block; + width: 100%; + height: 100%; +} + +.viewer-status { + position: absolute; + top: 52px; + left: 50%; + z-index: 5; + max-width: min(480px, calc(100% - 32px)); + padding: 8px 11px; + border: 1px solid var(--scene-panel-border); + border-radius: var(--radius-sm); + background: var(--scene-panel-bg-soft); + color: var(--scene-text); + font-size: 0.8rem; + font-weight: 760; + line-height: 1.35; + text-align: center; + transform: translateX(-50%); + backdrop-filter: blur(12px); + pointer-events: none; +} + +.viewer-status[data-state='ready'], +.viewer-status[data-state='loading'] { + display: none; +} + +.viewer-status[data-state='error'] { + border-color: color-mix(in srgb, var(--danger) 62%, var(--scene-panel-border)); + color: var(--danger-on-scene); +} + +.viewer-stats { + position: absolute; + top: var(--viewer-panel-gap); + left: 50%; + z-index: 5; + display: inline-grid; + place-items: center; + min-width: 42px; + min-height: 30px; + margin: 0; + padding: 4px 9px; + border: 1px solid var(--scene-panel-border); + border-radius: var(--radius-sm); + background: var(--scene-panel-bg-strong); + color: var(--color-white); + font-family: var(--font-mono); + font-size: 0.95rem; + font-weight: 780; + line-height: 1; + text-align: center; + transform: translateX(-50%); + backdrop-filter: blur(14px); +} + +.viewer-config-panel { + --tool-panel-label-width: 90px; + border: 0; + background: transparent; + box-shadow: none; +} + +.viewer-drag-overlay { + position: absolute; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: var(--viewer-panel-gap); + overflow: hidden; + border: 0; + background: color-mix(in srgb, var(--scene-bg) 92%, transparent); + color: var(--scene-text); + opacity: 0; + pointer-events: none; + transform: scale(0.992); + transition: + opacity 140ms ease, + transform 160ms ease; + backdrop-filter: blur(12px) brightness(0.72); +} + +.viewer-drag-overlay::before { + content: ''; + position: absolute; + inset: var(--viewer-panel-gap); + border-radius: calc(var(--radius) + 8px); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 78%, transparent); + pointer-events: none; +} + +.viewer-shell[data-file-dragging='true'] .viewer-drag-overlay { + opacity: 1; + transform: scale(1); +} + +.viewer-shell[data-file-dragging='true'] .viewer-left-rail, +.viewer-shell[data-file-dragging='true'] .viewer-left-toggle, +.viewer-shell[data-file-dragging='true'] .viewer-right-toggle, +.viewer-shell[data-file-dragging='true'] .viewer-right-rail { + opacity: 0; + pointer-events: none; +} + +.viewer-drag-overlay-panel { + position: relative; + z-index: 1; + display: grid; + justify-items: center; + gap: 12px; + max-width: min(420px, calc(100% - 40px)); + text-align: center; +} + +.viewer-drag-overlay-panel strong { + color: var(--color-white); + font-size: clamp(1.35rem, 3vw, 2rem); + font-weight: 820; + letter-spacing: 0; + line-height: 1.15; +} + +.viewer-drag-overlay-panel span { + color: var(--scene-text-muted); + font-size: 0.95rem; + font-weight: 700; +} + +.viewer-drag-overlay-icon { + position: relative; + width: 68px; + height: 68px; + border: 4px solid color-mix(in srgb, var(--primary) 88%, var(--color-white)); + border-radius: 12px; + color: color-mix(in srgb, var(--primary) 88%, var(--color-white)); +} + +.viewer-drag-overlay-icon::before { + content: ''; + position: absolute; + right: 14px; + bottom: 14px; + width: 26px; + height: 26px; + border-right: 4px solid currentColor; + border-bottom: 4px solid currentColor; +} + +.viewer-drag-overlay-icon::after { + content: ''; + position: absolute; + right: 15px; + bottom: 28px; + width: 32px; + height: 4px; + border-radius: var(--radius-pill); + background: currentColor; + transform: rotate(135deg); + transform-origin: right center; +} + +:root[data-workspace-fullscreen='true'] .viewer-shell { + height: 100dvh; + min-height: 100dvh; +} + +:root[data-workspace-fullscreen='true'] .viewer-left-rail, +:root[data-workspace-fullscreen='true'] .viewer-left-toggle, +:root[data-workspace-fullscreen='true'] .viewer-right-toggle, +:root[data-workspace-fullscreen='true'] .viewer-right-rail { + display: none; +} + +@media (max-width: 1180px) { + .viewer-shell { + --viewer-left-width: min(264px, calc(100vw - 28px)); + --viewer-right-width: min(272px, calc(100vw - 28px)); + } +} + +@media (max-width: 900px) { + .viewer-shell { + --viewer-panel-gap: 10px; + --viewer-left-width: min(260px, calc(100vw - 20px)); + --viewer-right-width: min(264px, calc(100vw - 20px)); + height: calc(100dvh - var(--header-height-compact)); + min-height: 540px; + } + + .viewer-button-row:not(.viewer-camera-actions) { + grid-template-columns: 1fr; + } + + .viewer-status { + top: 48px; + } +} + +@media (max-width: 720px) { + .viewer-left-rail { + top: 54px; + right: var(--viewer-panel-gap); + bottom: auto; + width: auto; + max-height: calc(62% - 76px); + } + + .viewer-right-rail { + top: 54px; + right: var(--viewer-panel-gap); + bottom: auto; + left: auto; + width: var(--viewer-right-width); + max-height: calc(100% - 64px); + } + + .viewer-shell:is([data-left-collapsed='auto'], [data-left-collapsed='true']) .viewer-left-rail { + opacity: 0; + pointer-events: none; + transform: translateY(calc(-100% - 66px)); + } + + :root[data-viewer-left-collapsed='false'] .viewer-shell[data-left-collapsed='auto'] .viewer-left-rail { + opacity: 1; + pointer-events: auto; + transform: none; + } + + :root[data-viewer-left-collapsed='false'] .viewer-shell[data-left-collapsed='auto'] .viewer-left-toggle { + opacity: 0; + pointer-events: none; + } + + :root[data-viewer-right-collapsed='false'] .viewer-shell[data-right-collapsed='auto'] .viewer-right-toggle { + opacity: 0; + pointer-events: none; + } + + .viewer-shell:is([data-left-collapsed='auto'], [data-left-collapsed='true']) .viewer-collapse-icon { + transform: rotate(45deg); + } + + :root[data-viewer-left-collapsed='false'] .viewer-shell[data-left-collapsed='auto'] .viewer-collapse-icon { + transform: rotate(-135deg); + } + + .viewer-status { + top: 48px; + right: var(--viewer-panel-gap); + left: var(--viewer-panel-gap); + max-width: none; + transform: none; + } + + .viewer-shell:is([data-left-collapsed='auto'], [data-left-collapsed='true']) .viewer-status { + left: var(--viewer-panel-gap); + transform: none; + } +} diff --git a/website/src/utils/api.ts b/website/src/utils/api.ts new file mode 100644 index 0000000..5fb05ca --- /dev/null +++ b/website/src/utils/api.ts @@ -0,0 +1,116 @@ +import type { Locale } from '../i18n/locales.js'; +// @ts-ignore +import { type ApiCategory, type ApiManifestEntry, apiManifest } from '../../.generated/api/manifest.js'; + +export type ApiEntry = ApiManifestEntry & { + html: string; + locale: Locale; +}; + +interface ApiEntryGroup { + category: ApiCategory; + label: string; + entries: ApiEntry[]; +} + +const knownApiCategories = new Set(apiManifest.categories.map(category => category.category)); +const knownApiNamespaces = new Set(apiManifest.namespaces.map(namespace => namespace.namespace)); + +const htmlModules = import.meta.glob('../../.generated/api/*/**/*.html', { + eager: true, + import: 'default', + query: '?raw', +}); + +const htmlByLocaleAndSlug = new Map( + Object.entries(htmlModules).map(([path, html]) => { + const match = path.match(/\/api\/([^/]+)\/(.+)\.html$/); + + if (!match) { + throw new Error(`Invalid API doc path: ${path}`); + } + + return [`${match[1]}/${match[2]}`, html]; + }), +); + +const apiEntries = apiManifest.entries + .flatMap(entry => + Object.values(localizedEntries(entry)).map(localizedEntry => { + if (!knownApiCategories.has(entry.category)) { + throw new Error(`Unknown API category "${entry.category}". Regenerate the API manifest.`); + } + + if (!knownApiNamespaces.has(entry.namespace)) { + throw new Error(`Unknown API namespace "${entry.namespace}". Regenerate the API manifest.`); + } + + return localizedEntry; + }), + ) + .sort((a, b) => a.order - b.order || a.title.localeCompare(b.title)); + +export function getApiEntries(locale: Locale) { + return apiEntries.filter(entry => entry.locale === locale); +} + +export function getApiEntryGroups(locale: Locale): ApiEntryGroup[] { + const entriesByCategory = new Map(); + + for (const entry of getApiEntries(locale)) { + const entries = entriesByCategory.get(entry.category); + + if (entries) { + entries.push(entry); + continue; + } + + entriesByCategory.set(entry.category, [entry]); + } + + return apiManifest.categories.flatMap(category => { + const entries = entriesByCategory.get(category.category); + + if (!entries?.length) { + return []; + } + + return { + category: category.category, + label: category.label, + entries, + }; + }); +} + +export function getApiNeighbors(locale: Locale, slug: string) { + const entries = getApiEntries(locale); + const index = entries.findIndex(entry => entry.slug === slug); + + return { + previous: index > 0 ? entries[index - 1] : undefined, + next: index >= 0 && index < entries.length - 1 ? entries[index + 1] : undefined, + }; +} + +function localizedEntries(entry: ApiManifestEntry) { + const entries: Partial> = {}; + + for (const [key, html] of htmlByLocaleAndSlug) { + const separatorIndex = key.indexOf('/'); + const locale = key.slice(0, separatorIndex) as Locale; + const slug = key.slice(separatorIndex + 1); + + if (slug !== entry.slug) { + continue; + } + + entries[locale] = { + ...entry, + html, + locale, + }; + } + + return entries; +} diff --git a/website/src/utils/examples.ts b/website/src/utils/examples.ts new file mode 100644 index 0000000..f5ce311 --- /dev/null +++ b/website/src/utils/examples.ts @@ -0,0 +1,70 @@ +import { getCollection } from 'astro:content'; +import type { ExampleData, ExampleSurface } from '../content.config.js'; +import type { Locale } from '../i18n/locales.js'; + +export type ExampleItem = Omit & { + surfaces: ExampleSurface[]; + slug: string; + code: string; +}; + +type ExampleEntry = { + id: string; + data: ExampleData; +}; + +const exampleSources = import.meta.glob('../content/examples/*.ts', { + query: '?raw', + import: 'default', + eager: true, +}); + +const allExamples: ExampleItem[] = (await getCollection('examples')) + .filter((e: ExampleEntry) => e.data.surfaces !== 'none') + .map( + (entry: ExampleEntry) => + ({ + slug: entry.id, + ...entry.data, + code: getExampleCode(entry.id), + }) as ExampleItem, + ) + .sort((a: ExampleItem, b: ExampleItem) => a.order - b.order); + +export const examples: ExampleItem[] = allExamples.filter(example => example.surfaces.includes('examples')); + +export const defaultExample = getDefaultExample(); + +export function getPlaygroundPresets(locale: Locale) { + return allExamples + .filter(example => example.surfaces.includes('playground')) + .map(example => ({ + slug: example.slug, + title: example.title[locale], + tags: example.tags, + code: example.code, + accent: example.accent, + renderer: example.renderer, + })); +} + +function getExampleCode(slug: string) { + const sourcePath = `../content/examples/${slug}.ts`; + const code = exampleSources[sourcePath]; + + if (!code) { + throw new Error(`Missing example source for "${slug}".`); + } + + return code; +} + +function getDefaultExample() { + const example = examples[0]; + + if (!example) { + throw new Error('At least one example is required.'); + } + + return example; +} diff --git a/website/src/utils/manual-assets.js b/website/src/utils/manual-assets.js new file mode 100644 index 0000000..ef87558 --- /dev/null +++ b/website/src/utils/manual-assets.js @@ -0,0 +1,41 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { posix as pathPosix } from 'node:path'; + +export const astroAssetsDir = '_astro'; +export const assetsPrefix = 'https://holo-cos.aholo3d.cn/aholo-opensource/page'; +export const buildHashLength = 16; +export const manualAssetsDir = 'manual'; +export const manualAssetPath = `/${astroAssetsDir}/${manualAssetsDir}`; + +export function getManualAssetBase(isProd = false) { + return isProd ? `${assetsPrefix}${manualAssetPath}` : manualAssetPath; +} + +export function getManualAssetOutputPath(relativeAssetPath, sourceFilePath, { hash = false } = {}) { + const normalizedPath = normalizeAssetPath(relativeAssetPath); + + if (!hash) { + return normalizedPath; + } + + return addContentHashToAssetPath(normalizedPath, readFileSync(sourceFilePath)); +} + +export function addContentHashToAssetPath(relativeAssetPath, content) { + const normalizedPath = normalizeAssetPath(relativeAssetPath); + const extension = pathPosix.extname(normalizedPath); + const fileName = pathPosix.basename(normalizedPath, extension); + const directory = pathPosix.dirname(normalizedPath); + const hashedFileName = `${fileName}.${createContentHash(content)}${extension}`; + + return directory === '.' ? hashedFileName : `${directory}/${hashedFileName}`; +} + +function createContentHash(content) { + return createHash('sha256').update(content).digest('base64url').slice(0, buildHashLength); +} + +function normalizeAssetPath(assetPath) { + return assetPath.replace(/\\/g, '/'); +} diff --git a/website/src/utils/manual.ts b/website/src/utils/manual.ts new file mode 100644 index 0000000..7808c45 --- /dev/null +++ b/website/src/utils/manual.ts @@ -0,0 +1,378 @@ +import { existsSync, promises as fs } from 'fs'; +import { dirname, extname, isAbsolute, relative, resolve } from 'path'; +import { pathToFileURL } from 'url'; +import { createSatteriMarkdownProcessor } from '@astrojs/markdown-satteri'; +import { parse as parseYaml } from 'yaml'; +import type { Locale } from '../i18n/locales.js'; +import { apiManifest } from '../../.generated/api/manifest.js'; +import { getManualAssetBase, getManualAssetOutputPath } from './manual-assets.js'; + +interface ManualHeading { + depth: number; + slug: string; + text: string; +} + +interface ManualFrontmatter { + title: string; + description: string; + order: number; +} + +interface ManualEntry extends ManualFrontmatter { + locale: Locale; + slug: string; + headings: ManualHeading[]; + html: string; +} + +interface ManualIndexEntry extends ManualFrontmatter { + locale: Locale; + slug: string; +} + +type ApiManifestEntry = (typeof apiManifest.entries)[number]; + +const manualRoot = resolve(process.cwd(), 'src/content/manual'); +const manualAssetRoot = resolve(manualRoot, 'assets'); +const manualAssetBase = getManualAssetBase(import.meta.env.PROD); +const shouldHashManualAssets = import.meta.env.PROD; +const markdownProcessor = createSatteriMarkdownProcessor(); +const apiEntriesBySymbol = createApiEntriesBySymbol(); + +export async function getManualEntries(locale: Locale): Promise { + const localeDir = resolve(manualRoot, locale); + + if (!isWithinManualRoot(localeDir) || !existsSync(localeDir)) { + return []; + } + + const entries = await fs.readdir(localeDir, { withFileTypes: true }); + const pages = await Promise.all( + entries + .filter(entry => entry.isFile() && extname(entry.name) === '.md') + .map(entry => readManualMetadata(locale, entry.name.replace(/\.md$/, ''))), + ); + + return pages.sort((a, b) => a.order - b.order); +} + +export async function getManualEntry(locale: Locale, slug: string): Promise { + const filePath = resolveManualFile(locale, slug); + + if (!filePath || !existsSync(filePath)) { + return undefined; + } + + const source = await fs.readFile(filePath, 'utf8'); + const { content, frontmatter } = parseManualFrontmatter(source); + const rendered = await ( + await markdownProcessor + ).render(rewriteManualReferences(content, filePath, locale), { + fileURL: new URL(pathToFileURL(filePath).href), + frontmatter, + }); + + return { + ...toManualMetadata(locale, slug, frontmatter), + headings: toManualHeadings(rendered.metadata.headings), + html: rendered.code, + }; +} + +export async function getManualNeighbors(locale: Locale, slug: string) { + const entries = await getManualEntries(locale); + const index = entries.findIndex(entry => entry.slug === slug); + + return { + previous: index > 0 ? entries[index - 1] : undefined, + next: index >= 0 && index < entries.length - 1 ? entries[index + 1] : undefined, + }; +} + +async function readManualMetadata(locale: Locale, slug: string): Promise { + const filePath = resolveManualFile(locale, slug); + + if (!filePath || !existsSync(filePath)) { + throw new Error(`Manual page not found: ${locale}/${slug}`); + } + + const { frontmatter } = parseManualFrontmatter(await fs.readFile(filePath, 'utf8')); + + return toManualMetadata(locale, slug, frontmatter); +} + +function resolveManualFile(locale: Locale, slug: string) { + const filePath = resolve(manualRoot, locale, `${slug}.md`); + + return isWithinManualRoot(filePath) ? filePath : undefined; +} + +function toManualMetadata(locale: Locale, slug: string, frontmatter: Record): ManualIndexEntry { + return { + locale, + slug, + title: getFrontmatterString(frontmatter, 'title', locale, slug), + description: getFrontmatterString(frontmatter, 'description', locale, slug), + order: getFrontmatterNumber(frontmatter, 'order', locale, slug), + }; +} + +function getFrontmatterString( + frontmatter: Record, + key: keyof ManualFrontmatter, + locale: Locale, + slug: string, +) { + const value = frontmatter[key]; + + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Manual page ${locale}/${slug} is missing frontmatter string: ${key}`); + } + + return value; +} + +function getFrontmatterNumber( + frontmatter: Record, + key: keyof ManualFrontmatter, + locale: Locale, + slug: string, +) { + const value = Number(frontmatter[key]); + + if (!Number.isFinite(value)) { + throw new Error(`Manual page ${locale}/${slug} is missing frontmatter number: ${key}`); + } + + return value; +} + +function toManualHeadings(headings: ManualHeading[]): ManualHeading[] { + return headings.map(heading => ({ + depth: heading.depth, + slug: heading.slug, + text: heading.text, + })); +} + +function parseManualFrontmatter(source: string) { + const match = source.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/); + + if (!match) { + return { + content: source, + frontmatter: {}, + }; + } + + const parsedFrontmatter = parseYaml(match[1]) ?? {}; + + if (!isRecord(parsedFrontmatter)) { + throw new Error('Manual frontmatter must be a YAML object.'); + } + + return { + content: source.slice(match[0].length), + frontmatter: parsedFrontmatter, + }; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function rewriteManualReferences(content: string, filePath: string, locale: Locale) { + return rewriteManualLinkReferences(rewriteManualAssetReferences(content, filePath), filePath, locale); +} + +function rewriteManualAssetReferences(content: string, filePath: string) { + return content.replace(/(!\[[^\]]*]\()([^)]+)(\))/g, (match, opening, target, closing) => { + const rewrittenTarget = toManualAssetReference(filePath, target); + + return rewrittenTarget ? `${opening}${rewrittenTarget}${closing}` : match; + }); +} + +function rewriteManualLinkReferences(content: string, filePath: string, locale: Locale) { + const markdownLinks = content.replace( + /(^|[^!])(\[[^\]]*]\()([^)]+)(\))/g, + (match, prefix, opening, target, closing) => { + const rewrittenTarget = toManualLinkReference(filePath, locale, target); + + return rewrittenTarget ? `${prefix}${opening}${rewrittenTarget}${closing}` : match; + }, + ); + + return markdownLinks.replace(/\bhref=(["'])([^"']+)\1/g, (match, quote, target) => { + const rewrittenTarget = toManualLinkReference(filePath, locale, target); + + return rewrittenTarget ? `href=${quote}${rewrittenTarget}${quote}` : match; + }); +} + +function toManualAssetReference(filePath: string, target: string) { + const parsedTarget = parseMarkdownTarget(target); + + if (!parsedTarget || !isLocalReference(parsedTarget.href)) { + return undefined; + } + + const { pathname, trailing } = splitPathnameAndTrailing(parsedTarget.href); + const assetPath = resolve(dirname(filePath), decodeURIComponent(pathname)); + + if (!isWithinManualAssetRoot(assetPath)) { + return undefined; + } + + const relativeAssetPath = toPosixPath(relative(manualAssetRoot, assetPath)); + const outputAssetPath = getManualAssetOutputPath(relativeAssetPath, assetPath, { hash: shouldHashManualAssets }); + + return `${manualAssetBase}/${encodeAssetPath(outputAssetPath)}${trailing}${parsedTarget.suffix}`; +} + +function toManualLinkReference(filePath: string, locale: Locale, target: string) { + return toManualApiReference(locale, target) ?? toManualPageReference(filePath, locale, target); +} + +function toManualApiReference(locale: Locale, target: string) { + const parsedTarget = parseMarkdownTarget(target); + + if (!parsedTarget) { + return undefined; + } + + const { pathname, trailing } = splitPathnameAndTrailing(parsedTarget.href); + + if (!pathname.startsWith('api:')) { + return undefined; + } + + const symbol = decodeURIComponent(pathname.slice('api:'.length)).trim(); + + if (!symbol) { + throw new Error(`Manual API link is missing a symbol: ${target}`); + } + + const entry = resolveApiEntry(symbol); + + if (!entry) { + throw new Error(`Manual API link target not found: ${symbol}`); + } + + return `/${locale}/api/${encodeRoutePath(entry.slug)}/${trailing}${parsedTarget.suffix}`; +} + +function toManualPageReference(filePath: string, locale: Locale, target: string) { + const parsedTarget = parseMarkdownTarget(target); + + if (!parsedTarget || !isLocalReference(parsedTarget.href)) { + return undefined; + } + + const { pathname, trailing } = splitPathnameAndTrailing(parsedTarget.href); + + if (extname(pathname).toLowerCase() !== '.md') { + return undefined; + } + + const localeRoot = resolve(manualRoot, locale); + const pagePath = resolve(dirname(filePath), decodeURIComponent(pathname)); + + if (!isWithinRoot(localeRoot, pagePath)) { + return undefined; + } + + const slug = toPosixPath(relative(localeRoot, pagePath)).replace(/\.md$/i, ''); + + if (!slug) { + return undefined; + } + + return `/${locale}/manual/${encodeRoutePath(slug)}/${trailing}${parsedTarget.suffix}`; +} + +function parseMarkdownTarget(target: string) { + const trimmed = target.trim(); + const match = trimmed.match(/^(\S+)(.*)$/s); + + if (!match) { + return undefined; + } + + return { + href: match[1], + suffix: match[2], + }; +} + +function isLocalReference(href: string) { + return !href.startsWith('/') && !href.startsWith('#') && !/^[a-z][a-z\d+.-]*:/i.test(href); +} + +function splitPathnameAndTrailing(href: string) { + const match = href.match(/^([^?#]*)([?#].*)?$/s); + + return { + pathname: match?.[1] ?? href, + trailing: match?.[2] ?? '', + }; +} + +function encodeAssetPath(assetPath: string) { + return assetPath.split('/').map(encodeURIComponent).join('/'); +} + +function encodeRoutePath(routePath: string) { + return routePath.split('/').map(encodeURIComponent).join('/'); +} + +function resolveApiEntry(symbol: string) { + const entries = apiEntriesBySymbol.get(symbol) ?? []; + + if (entries.length > 1) { + throw new Error(`Manual API link target is ambiguous: ${symbol}`); + } + + return entries[0]; +} + +function createApiEntriesBySymbol() { + const entriesBySymbol = new Map(); + + for (const entry of apiManifest.entries) { + addApiEntrySymbol(entriesBySymbol, entry.title, entry); + addApiEntrySymbol(entriesBySymbol, `${entry.namespace}.${entry.title}`, entry); + } + + return entriesBySymbol; +} + +function addApiEntrySymbol(entriesBySymbol: Map, symbol: string, entry: ApiManifestEntry) { + const entries = entriesBySymbol.get(symbol); + + if (entries) { + entries.push(entry); + return; + } + + entriesBySymbol.set(symbol, [entry]); +} + +function isWithinManualRoot(filePath: string) { + return isWithinRoot(manualRoot, filePath); +} + +function isWithinManualAssetRoot(filePath: string) { + return isWithinRoot(manualAssetRoot, filePath); +} + +function isWithinRoot(root: string, filePath: string) { + const relativePath = relative(root, filePath); + + return relativePath === '' || (!!relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)); +} + +function toPosixPath(value: string) { + return value.replace(/\\/g, '/'); +} diff --git a/website/src/utils/navigation.ts b/website/src/utils/navigation.ts new file mode 100644 index 0000000..fb5f7a4 --- /dev/null +++ b/website/src/utils/navigation.ts @@ -0,0 +1,41 @@ +import type { Locale } from '../i18n/locales.js'; +import { getApiEntryGroups } from './api.js'; +import { getManualEntries } from './manual.js'; + +export const navItems = [ + { key: 'home', href: '/' }, + { key: 'viewer', href: '/viewer/' }, + { key: 'examples', href: '/examples/' }, + { key: 'playground', href: '/playground/' }, + { key: 'manual', href: '/manual/' }, + { key: 'api', href: '/api/' }, +] as const; + +export interface SidebarItem { + href?: string; + label: string; + description?: string; + searchText?: string; + items?: SidebarItem[]; +} + +export async function manualSidebarItems(locale: Locale): Promise { + return (await getManualEntries(locale)).map(page => ({ + href: `/${locale}/manual/${page.slug}/`, + label: page.title, + description: page.description, + })); +} + +export function apiSidebarItems(locale: Locale): SidebarItem[] { + return getApiEntryGroups(locale).map(group => ({ + label: group.label, + items: group.entries.map(entry => ({ + href: `/${locale}/api/${entry.slug}/`, + label: entry.title, + description: + entry.namespaceLabel === group.label ? entry.kindLabel : `${entry.namespaceLabel} / ${entry.kindLabel}`, + searchText: [entry.namespaceLabel, entry.categoryLabel, entry.kindLabel].join(' '), + })), + })); +} diff --git a/website/tsconfig.json b/website/tsconfig.json new file mode 100644 index 0000000..f2f0966 --- /dev/null +++ b/website/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"], + "exclude": ["dist"] +}