From acd8e21031332fe72d52a714e9cd1bf3af919744 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 13:18:05 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .codex/config.toml | 1 + .codex/skills/aholo-docs/SKILL.md | 38 + .codex/skills/aholo-docs/agents/openai.yaml | 4 + .codex/skills/aholo-examples/SKILL.md | 46 + .../skills/aholo-examples/agents/openai.yaml | 4 + .codex/skills/aholo-renderer/SKILL.md | 24 + .../skills/aholo-renderer/agents/openai.yaml | 4 + .codex/skills/aholo-site/SKILL.md | 41 + .codex/skills/aholo-site/agents/openai.yaml | 4 + .codex/skills/aholo-viewer/SKILL.md | 35 + .codex/skills/aholo-viewer/agents/openai.yaml | 4 + .codex/skills/frontend-design/LICENSE.txt | 177 + .codex/skills/frontend-design/SKILL.md | 30 + .../skills/frontend-design/agents/openai.yaml | 4 + .editorconfig | 13 + .gitattributes | 2 + .github/ISSUE_TEMPLATE/bug_report.md | 57 + .github/ISSUE_TEMPLATE/custom.md | 7 + .github/ISSUE_TEMPLATE/feature_request.md | 19 + .github/workflows/build-native.yaml | 144 + .github/workflows/check.yaml | 26 + .github/workflows/publish.yaml | 123 + .gitignore | 12 + .gitmodules | 4 + .husky/pre-commit | 2 + .vscode/extensions.json | 7 + .vscode/settings.json | 25 + AGENTS.md | 64 + LICENSE | 21 + README.md | 147 + README.wehub.md | 7 + ci/check.sh | 10 + ci/publish.sh | 11 + docs/ai/skills/use-aholo-viewer/SKILL.md | 169 + .../skills/use-aholo-viewer/agents/claude.md | 50 + .../use-aholo-viewer/agents/openai.yaml | 4 + docs/ai/vibe-coding-guide.md | 90 + docs/architecture.md | 211 + external/splat-dev-server/CHANGELOG.md | 5 + external/splat-dev-server/LICENSE | 21 + external/splat-dev-server/README.md | 22 + external/splat-dev-server/bin/merge.js | 26 + external/splat-dev-server/bin/server.js | 25 + external/splat-dev-server/package.json | 40 + external/splat-dev-server/src/merge.js | 76 + external/splat-dev-server/src/server.js | 51 + lint-staged.config.js | 4 + oxfmt.config.ts | 23 + oxlint.config.ts | 42 + package.json | 48 + packages/renderer/CHANGELOG.md | 75 + packages/renderer/LICENSE | 21 + packages/renderer/README.md | 28 + packages/renderer/package.json | 62 + packages/renderer/src/animation.ts | 13 + packages/renderer/src/draco-loader.ts | 1 + packages/renderer/src/events.ts | 1 + packages/renderer/src/gltf-loader.ts | 1 + packages/renderer/src/index.ts | 376 ++ packages/renderer/src/splat-loader.ts | 21 + packages/renderer/src/splat-utils.ts | 16 + packages/renderer/tsconfig.json | 15 + .../splat-transform-native/.gitattributes | 2 + packages/splat-transform-native/.gitignore | 1 + .../source/.clang-format | 23 + .../source/CMakeLists.txt | 74 + .../source/CMakePresets.json | 155 + .../splat-transform-native/source/build.js | 47 + .../share/cmake-js/cmake-js-config.cmake | 25 + .../source/include/container_helpers.h | 39 + .../source/include/future_helpers.h | 135 + .../source/include/node/api_avif.h | 9 + .../source/include/node/api_buffer.h | 92 + .../source/include/node/api_spatial.h | 6 + .../source/include/node/api_splat.h | 7 + .../source/include/node/api_thread_pool.h | 26 + .../source/include/node/api_webp.h | 8 + .../source/include/splat/splat.h | 60 + .../source/include/splat/splat_block.h | 17 + .../source/include/splat/splat_lod.h | 43 + .../source/include/thread_pool.h | 122 + .../source/package.json | 23 + .../source/src/node/api_avif.cpp | 301 + .../source/src/node/api_spatial.cpp | 150 + .../source/src/node/api_splat.cpp | 357 + .../source/src/node/api_thread_pool.cpp | 42 + .../source/src/node/api_webp.cpp | 108 + .../source/src/node/binding.cpp | 57 + .../source/src/splat/splat.cpp | 188 + .../source/src/splat/splat_block.cpp | 137 + .../source/src/splat/splat_lod.cpp | 396 ++ .../source/src/thread_pool.cpp | 134 + .../source/vcpkg-configuration.json | 14 + .../splat-transform-native/source/vcpkg.json | 11 + .../splat-transform-darwin-arm64/dawn.node | 3 + .../splat-transform-darwin-arm64/package.json | 15 + .../splat-transform-linux-arm64-gnu/dawn.node | 3 + .../package.json | 18 + .../splat-transform-linux-x64-gnu/dawn.node | 3 + .../package.json | 18 + .../d3dcompiler_47.dll | 3 + .../dawn.node | 3 + .../dxcompiler.dll | 3 + .../splat-transform-win32-arm64-msvc/dxil.dll | 3 + .../package.json | 18 + .../d3dcompiler_47.dll | 3 + .../splat-transform-win32-x64-msvc/dawn.node | 3 + .../dxcompiler.dll | 3 + .../splat-transform-win32-x64-msvc/dxil.dll | 3 + .../package.json | 18 + packages/splat-transform/.gitignore | 3 + packages/splat-transform/CHANGELOG.md | 194 + packages/splat-transform/LICENSE | 21 + packages/splat-transform/README.md | 40 + .../splat-transform/THIRD_PARTY_LICENSES.txt | 1373 ++++ packages/splat-transform/bin/cli.js | 199 + packages/splat-transform/package.json | 67 + packages/splat-transform/pipeline.json | 31 + packages/splat-transform/scripts/build.js | 29 + packages/splat-transform/src/SplatData.ts | 231 + packages/splat-transform/src/constant.ts | 15 + packages/splat-transform/src/file/IFile.ts | 6 + packages/splat-transform/src/file/esz.ts | 430 ++ packages/splat-transform/src/file/index.ts | 8 + packages/splat-transform/src/file/ksplat.ts | 385 ++ packages/splat-transform/src/file/lcc.ts | 214 + packages/splat-transform/src/file/ply.ts | 595 ++ packages/splat-transform/src/file/sog.ts | 694 ++ packages/splat-transform/src/file/splat.ts | 131 + packages/splat-transform/src/file/spz.ts | 679 ++ packages/splat-transform/src/file/voxel.ts | 828 +++ packages/splat-transform/src/index.ts | 80 + packages/splat-transform/src/native/index.ts | 352 + packages/splat-transform/src/native/utils.ts | 68 + .../src/tasks/AutoChunkLodTask.ts | 156 + .../splat-transform/src/tasks/AutoLodTask.ts | 39 + .../splat-transform/src/tasks/BaseTask.ts | 21 + .../splat-transform/src/tasks/FlexLodTask.ts | 71 + .../splat-transform/src/tasks/ModifyTask.ts | 209 + .../splat-transform/src/tasks/ReadTask.ts | 36 + .../src/tasks/SkeletonLodTask.ts | 195 + .../src/tasks/SplitSplatTask.ts | 33 + .../splat-transform/src/tasks/VoxelTask.ts | 83 + .../splat-transform/src/tasks/WriteTask.ts | 176 + .../splat-transform/src/utils/BufferReader.ts | 54 + packages/splat-transform/src/utils/Logger.ts | 45 + .../src/utils/StreamChunkDecoder.ts | 46 + packages/splat-transform/src/utils/index.ts | 112 + .../src/utils/k-means/GpuClustering.ts | 399 ++ .../src/utils/k-means/index.ts | 80 + packages/splat-transform/src/utils/math.ts | 441 ++ .../splat-transform/src/utils/quantize1d.ts | 194 + .../splat-transform/src/utils/shRotate.ts | 246 + packages/splat-transform/src/utils/splat.ts | 467 ++ .../splat-transform/src/utils/voxel/binary.ts | 199 + .../splat-transform/src/utils/voxel/common.ts | 2074 ++++++ .../src/utils/voxel/coplanarMerge.ts | 866 +++ .../src/utils/voxel/filterCluster.ts | 1015 +++ .../src/utils/voxel/gpuDilation.ts | 938 +++ .../src/utils/voxel/marchingCubes.ts | 1853 ++++++ .../splat-transform/src/utils/voxel/mesh.ts | 154 + .../splat-transform/src/utils/voxel/nav.ts | 1167 ++++ .../src/utils/voxel/postprocess.ts | 565 ++ .../src/utils/voxel/voxelFaces.ts | 736 ++ .../src/utils/voxel/voxelize.ts | 1281 ++++ packages/splat-transform/src/utils/webgpu.ts | 147 + packages/splat-transform/src/utils/worker.ts | 217 + .../splat-transform/src/workers/voxelize.ts | 190 + packages/splat-transform/src/workers/write.ts | 28 + packages/splat-transform/tsconfig.json | 18 + pnpm-lock.yaml | 5918 +++++++++++++++++ pnpm-workspace.yaml | 38 + scripts/build-package.mjs | 349 + scripts/check-content.mjs | 465 ++ scripts/clean-package.mjs | 46 + scripts/ensure-submodules.mjs | 99 + scripts/generate-api-docs.mjs | 865 +++ scripts/generate-llm-docs.mjs | 661 ++ scripts/package-utils.mjs | 156 + scripts/prepare-egs-types.mjs | 43 + scripts/publish-packages.mjs | 74 + scripts/read-package-changelog.mjs | 60 + tsconfig.base.json | 10 + website/astro.config.mjs | 473 ++ website/package.json | 31 + website/public/aholo-logo.svg | 65 + website/public/favicon.ico | Bin 0 -> 16958 bytes website/public/home/feature-tools.svg | 257 + website/public/icons/example-edit-code.svg | 4 + website/scripts/clean.mjs | 37 + website/src/client/camera-control.ts | 979 +++ website/src/client/docs.ts | 110 + website/src/client/examples.ts | 284 + website/src/client/home.ts | 235 + website/src/client/interaction-guide.ts | 100 + website/src/client/playground.ts | 749 +++ website/src/client/render-runtime.d.ts | 55 + website/src/client/render-runtime.ts | 941 +++ website/src/client/rendering.ts | 77 + website/src/client/split-pane.ts | 241 + website/src/client/viewer.ts | 1596 +++++ website/src/client/workspace-fullscreen.ts | 323 + website/src/components/ExamplePreview.astro | 67 + website/src/components/ExampleRail.astro | 49 + website/src/components/InteractionGuide.astro | 302 + website/src/components/PlaygroundShell.astro | 201 + website/src/components/ViewerShell.astro | 272 + website/src/components/docs/DocPager.astro | 36 + website/src/components/docs/DocToc.astro | 30 + website/src/components/site/Breadcrumbs.astro | 21 + website/src/components/site/Header.astro | 203 + .../src/components/site/LanguageSwitch.astro | 42 + website/src/components/site/Sidebar.astro | 263 + website/src/components/site/ThemeToggle.astro | 15 + website/src/content.config.ts | 75 + .../content/examples/3d-buffer-geometry.json | 15 + .../content/examples/3d-buffer-geometry.ts | 85 + .../src/content/examples/3d-point-light.json | 15 + .../src/content/examples/3d-point-light.ts | 190 + .../content/examples/home-interaction.json | 15 + .../src/content/examples/home-interaction.ts | 152 + .../src/content/examples/splatting-basic.json | 14 + .../src/content/examples/splatting-basic.ts | 136 + .../examples/splatting-lod-stream.json | 14 + .../content/examples/splatting-lod-stream.ts | 262 + website/src/content/examples/walk-demo.json | 15 + website/src/content/examples/walk-demo.ts | 2257 +++++++ .../compressed-ply-quantization.png | Bin 0 -> 12324 bytes .../normalized-falloff-off.png | Bin 0 -> 27541 bytes .../normalized-falloff-on.png | Bin 0 -> 26306 bytes .../preset-extreme-performance-0-result.png | Bin 0 -> 2607989 bytes .../preset-extreme-performance-1-result.png | Bin 0 -> 2583431 bytes .../preset-max-quality-result.png | Bin 0 -> 1840241 bytes .../preset-performance-first-result.png | Bin 0 -> 2656930 bytes .../preset-quality-first-result.png | Bin 0 -> 2618709 bytes .../basic-concepts/geometry-attributes.png | Bin 0 -> 20381 bytes .../assets/basic-concepts/material-types.png | Bin 0 -> 23116 bytes .../basic-concepts/object3d-hierarchy.png | Bin 0 -> 97503 bytes .../basic-concepts/orthographic-camera.png | Bin 0 -> 34175 bytes .../basic-concepts/perspective-camera.png | Bin 0 -> 144889 bytes .../assets/basic-concepts/render-flow.png | Bin 0 -> 44644 bytes .../assets/basic-concepts/scene-tree.png | Bin 0 -> 46369 bytes .../manual/assets/basic-concepts/viewport.png | Bin 0 -> 44538 bytes .../manual/assets/chunk-lod/structure.png | Bin 0 -> 33484 bytes .../manual/assets/getting-started/example.png | Bin 0 -> 52266 bytes .../assets/playground/playground.en-US.jpeg | Bin 0 -> 173151 bytes .../assets/playground/playground.zh-CN.jpeg | Bin 0 -> 172986 bytes .../manual/assets/viewer/viewer.en-US.jpeg | Bin 0 -> 56727 bytes .../manual/assets/viewer/viewer.zh-CN.jpeg | Bin 0 -> 54426 bytes .../manual/en-US/3dgs-preset-config.md | 325 + .../content/manual/en-US/basic-concepts.md | 141 + website/src/content/manual/en-US/chunk-lod.md | 81 + website/src/content/manual/en-US/config.md | 26 + .../content/manual/en-US/getting-started.md | 127 + .../content/manual/en-US/physics-collision.md | 90 + .../src/content/manual/en-US/playground.md | 70 + .../content/manual/en-US/splat-transform.md | 454 ++ website/src/content/manual/en-US/viewer.md | 73 + .../manual/zh-CN/3dgs-preset-config.md | 325 + .../content/manual/zh-CN/basic-concepts.md | 141 + website/src/content/manual/zh-CN/chunk-lod.md | 81 + website/src/content/manual/zh-CN/config.md | 26 + .../content/manual/zh-CN/getting-started.md | 127 + .../content/manual/zh-CN/physics-collision.md | 90 + .../src/content/manual/zh-CN/playground.md | 70 + .../content/manual/zh-CN/splat-transform.md | 479 ++ website/src/content/manual/zh-CN/viewer.md | 74 + website/src/env.d.ts | 3 + website/src/i18n/dictionary.ts | 112 + website/src/i18n/locales.ts | 22 + website/src/i18n/routes.ts | 12 + website/src/layouts/BaseLayout.astro | 130 + website/src/layouts/DocsLayout.astro | 91 + website/src/pages/[lang]/api/[...slug].astro | 64 + website/src/pages/[lang]/api/index.astro | 62 + .../src/pages/[lang]/examples/[slug].astro | 133 + website/src/pages/[lang]/examples/index.astro | 35 + website/src/pages/[lang]/index.astro | 281 + .../src/pages/[lang]/manual/[...slug].astro | 67 + website/src/pages/[lang]/manual/index.astro | 47 + website/src/pages/[lang]/playground.astro | 31 + website/src/pages/[lang]/viewer.astro | 31 + website/src/pages/index.astro | 78 + website/src/styles/docs.css | 1372 ++++ website/src/styles/examples.css | 367 + website/src/styles/global.css | 102 + website/src/styles/home.css | 1057 +++ website/src/styles/playground.css | 449 ++ website/src/styles/site.css | 538 ++ website/src/styles/theme.css | 117 + website/src/styles/viewer.css | 964 +++ website/src/utils/api.ts | 116 + website/src/utils/examples.ts | 70 + website/src/utils/manual-assets.js | 41 + website/src/utils/manual.ts | 378 ++ website/src/utils/navigation.ts | 41 + website/tsconfig.json | 9 + 297 files changed, 56514 insertions(+) create mode 100644 .codex/config.toml create mode 100644 .codex/skills/aholo-docs/SKILL.md create mode 100644 .codex/skills/aholo-docs/agents/openai.yaml create mode 100644 .codex/skills/aholo-examples/SKILL.md create mode 100644 .codex/skills/aholo-examples/agents/openai.yaml create mode 100644 .codex/skills/aholo-renderer/SKILL.md create mode 100644 .codex/skills/aholo-renderer/agents/openai.yaml create mode 100644 .codex/skills/aholo-site/SKILL.md create mode 100644 .codex/skills/aholo-site/agents/openai.yaml create mode 100644 .codex/skills/aholo-viewer/SKILL.md create mode 100644 .codex/skills/aholo-viewer/agents/openai.yaml create mode 100644 .codex/skills/frontend-design/LICENSE.txt create mode 100644 .codex/skills/frontend-design/SKILL.md create mode 100644 .codex/skills/frontend-design/agents/openai.yaml create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/custom.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/build-native.yaml create mode 100644 .github/workflows/check.yaml create mode 100644 .github/workflows/publish.yaml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .husky/pre-commit create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 ci/check.sh create mode 100644 ci/publish.sh create mode 100644 docs/ai/skills/use-aholo-viewer/SKILL.md create mode 100644 docs/ai/skills/use-aholo-viewer/agents/claude.md create mode 100644 docs/ai/skills/use-aholo-viewer/agents/openai.yaml create mode 100644 docs/ai/vibe-coding-guide.md create mode 100644 docs/architecture.md create mode 100644 external/splat-dev-server/CHANGELOG.md create mode 100644 external/splat-dev-server/LICENSE create mode 100644 external/splat-dev-server/README.md create mode 100755 external/splat-dev-server/bin/merge.js create mode 100755 external/splat-dev-server/bin/server.js create mode 100644 external/splat-dev-server/package.json create mode 100644 external/splat-dev-server/src/merge.js create mode 100644 external/splat-dev-server/src/server.js create mode 100644 lint-staged.config.js create mode 100644 oxfmt.config.ts create mode 100644 oxlint.config.ts create mode 100644 package.json create mode 100644 packages/renderer/CHANGELOG.md create mode 100644 packages/renderer/LICENSE create mode 100644 packages/renderer/README.md create mode 100644 packages/renderer/package.json create mode 100644 packages/renderer/src/animation.ts create mode 100644 packages/renderer/src/draco-loader.ts create mode 100644 packages/renderer/src/events.ts create mode 100644 packages/renderer/src/gltf-loader.ts create mode 100644 packages/renderer/src/index.ts create mode 100644 packages/renderer/src/splat-loader.ts create mode 100644 packages/renderer/src/splat-utils.ts create mode 100644 packages/renderer/tsconfig.json create mode 100644 packages/splat-transform-native/.gitattributes create mode 100644 packages/splat-transform-native/.gitignore create mode 100644 packages/splat-transform-native/source/.clang-format create mode 100644 packages/splat-transform-native/source/CMakeLists.txt create mode 100644 packages/splat-transform-native/source/CMakePresets.json create mode 100644 packages/splat-transform-native/source/build.js create mode 100644 packages/splat-transform-native/source/cmake/share/cmake-js/cmake-js-config.cmake create mode 100644 packages/splat-transform-native/source/include/container_helpers.h create mode 100644 packages/splat-transform-native/source/include/future_helpers.h create mode 100644 packages/splat-transform-native/source/include/node/api_avif.h create mode 100644 packages/splat-transform-native/source/include/node/api_buffer.h create mode 100644 packages/splat-transform-native/source/include/node/api_spatial.h create mode 100644 packages/splat-transform-native/source/include/node/api_splat.h create mode 100644 packages/splat-transform-native/source/include/node/api_thread_pool.h create mode 100644 packages/splat-transform-native/source/include/node/api_webp.h create mode 100644 packages/splat-transform-native/source/include/splat/splat.h create mode 100644 packages/splat-transform-native/source/include/splat/splat_block.h create mode 100644 packages/splat-transform-native/source/include/splat/splat_lod.h create mode 100644 packages/splat-transform-native/source/include/thread_pool.h create mode 100644 packages/splat-transform-native/source/package.json create mode 100644 packages/splat-transform-native/source/src/node/api_avif.cpp create mode 100644 packages/splat-transform-native/source/src/node/api_spatial.cpp create mode 100644 packages/splat-transform-native/source/src/node/api_splat.cpp create mode 100644 packages/splat-transform-native/source/src/node/api_thread_pool.cpp create mode 100644 packages/splat-transform-native/source/src/node/api_webp.cpp create mode 100644 packages/splat-transform-native/source/src/node/binding.cpp create mode 100644 packages/splat-transform-native/source/src/splat/splat.cpp create mode 100644 packages/splat-transform-native/source/src/splat/splat_block.cpp create mode 100644 packages/splat-transform-native/source/src/splat/splat_lod.cpp create mode 100644 packages/splat-transform-native/source/src/thread_pool.cpp create mode 100644 packages/splat-transform-native/source/vcpkg-configuration.json create mode 100644 packages/splat-transform-native/source/vcpkg.json create mode 100644 packages/splat-transform-native/splat-transform-darwin-arm64/dawn.node create mode 100644 packages/splat-transform-native/splat-transform-darwin-arm64/package.json create mode 100644 packages/splat-transform-native/splat-transform-linux-arm64-gnu/dawn.node create mode 100644 packages/splat-transform-native/splat-transform-linux-arm64-gnu/package.json create mode 100644 packages/splat-transform-native/splat-transform-linux-x64-gnu/dawn.node create mode 100644 packages/splat-transform-native/splat-transform-linux-x64-gnu/package.json create mode 100644 packages/splat-transform-native/splat-transform-win32-arm64-msvc/d3dcompiler_47.dll create mode 100644 packages/splat-transform-native/splat-transform-win32-arm64-msvc/dawn.node create mode 100644 packages/splat-transform-native/splat-transform-win32-arm64-msvc/dxcompiler.dll create mode 100644 packages/splat-transform-native/splat-transform-win32-arm64-msvc/dxil.dll create mode 100644 packages/splat-transform-native/splat-transform-win32-arm64-msvc/package.json create mode 100644 packages/splat-transform-native/splat-transform-win32-x64-msvc/d3dcompiler_47.dll create mode 100644 packages/splat-transform-native/splat-transform-win32-x64-msvc/dawn.node create mode 100644 packages/splat-transform-native/splat-transform-win32-x64-msvc/dxcompiler.dll create mode 100644 packages/splat-transform-native/splat-transform-win32-x64-msvc/dxil.dll create mode 100644 packages/splat-transform-native/splat-transform-win32-x64-msvc/package.json create mode 100644 packages/splat-transform/.gitignore create mode 100644 packages/splat-transform/CHANGELOG.md create mode 100644 packages/splat-transform/LICENSE create mode 100644 packages/splat-transform/README.md create mode 100644 packages/splat-transform/THIRD_PARTY_LICENSES.txt create mode 100644 packages/splat-transform/bin/cli.js create mode 100644 packages/splat-transform/package.json create mode 100644 packages/splat-transform/pipeline.json create mode 100644 packages/splat-transform/scripts/build.js create mode 100644 packages/splat-transform/src/SplatData.ts create mode 100644 packages/splat-transform/src/constant.ts create mode 100644 packages/splat-transform/src/file/IFile.ts create mode 100644 packages/splat-transform/src/file/esz.ts create mode 100644 packages/splat-transform/src/file/index.ts create mode 100644 packages/splat-transform/src/file/ksplat.ts create mode 100644 packages/splat-transform/src/file/lcc.ts create mode 100644 packages/splat-transform/src/file/ply.ts create mode 100644 packages/splat-transform/src/file/sog.ts create mode 100644 packages/splat-transform/src/file/splat.ts create mode 100644 packages/splat-transform/src/file/spz.ts create mode 100644 packages/splat-transform/src/file/voxel.ts create mode 100644 packages/splat-transform/src/index.ts create mode 100644 packages/splat-transform/src/native/index.ts create mode 100644 packages/splat-transform/src/native/utils.ts create mode 100644 packages/splat-transform/src/tasks/AutoChunkLodTask.ts create mode 100644 packages/splat-transform/src/tasks/AutoLodTask.ts create mode 100644 packages/splat-transform/src/tasks/BaseTask.ts create mode 100644 packages/splat-transform/src/tasks/FlexLodTask.ts create mode 100644 packages/splat-transform/src/tasks/ModifyTask.ts create mode 100644 packages/splat-transform/src/tasks/ReadTask.ts create mode 100644 packages/splat-transform/src/tasks/SkeletonLodTask.ts create mode 100644 packages/splat-transform/src/tasks/SplitSplatTask.ts create mode 100644 packages/splat-transform/src/tasks/VoxelTask.ts create mode 100644 packages/splat-transform/src/tasks/WriteTask.ts create mode 100644 packages/splat-transform/src/utils/BufferReader.ts create mode 100644 packages/splat-transform/src/utils/Logger.ts create mode 100644 packages/splat-transform/src/utils/StreamChunkDecoder.ts create mode 100644 packages/splat-transform/src/utils/index.ts create mode 100644 packages/splat-transform/src/utils/k-means/GpuClustering.ts create mode 100644 packages/splat-transform/src/utils/k-means/index.ts create mode 100644 packages/splat-transform/src/utils/math.ts create mode 100644 packages/splat-transform/src/utils/quantize1d.ts create mode 100644 packages/splat-transform/src/utils/shRotate.ts create mode 100644 packages/splat-transform/src/utils/splat.ts create mode 100644 packages/splat-transform/src/utils/voxel/binary.ts create mode 100644 packages/splat-transform/src/utils/voxel/common.ts create mode 100644 packages/splat-transform/src/utils/voxel/coplanarMerge.ts create mode 100644 packages/splat-transform/src/utils/voxel/filterCluster.ts create mode 100644 packages/splat-transform/src/utils/voxel/gpuDilation.ts create mode 100644 packages/splat-transform/src/utils/voxel/marchingCubes.ts create mode 100644 packages/splat-transform/src/utils/voxel/mesh.ts create mode 100644 packages/splat-transform/src/utils/voxel/nav.ts create mode 100644 packages/splat-transform/src/utils/voxel/postprocess.ts create mode 100644 packages/splat-transform/src/utils/voxel/voxelFaces.ts create mode 100644 packages/splat-transform/src/utils/voxel/voxelize.ts create mode 100644 packages/splat-transform/src/utils/webgpu.ts create mode 100644 packages/splat-transform/src/utils/worker.ts create mode 100644 packages/splat-transform/src/workers/voxelize.ts create mode 100644 packages/splat-transform/src/workers/write.ts create mode 100644 packages/splat-transform/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/build-package.mjs create mode 100644 scripts/check-content.mjs create mode 100644 scripts/clean-package.mjs create mode 100644 scripts/ensure-submodules.mjs create mode 100644 scripts/generate-api-docs.mjs create mode 100644 scripts/generate-llm-docs.mjs create mode 100644 scripts/package-utils.mjs create mode 100644 scripts/prepare-egs-types.mjs create mode 100644 scripts/publish-packages.mjs create mode 100644 scripts/read-package-changelog.mjs create mode 100644 tsconfig.base.json create mode 100644 website/astro.config.mjs create mode 100644 website/package.json create mode 100644 website/public/aholo-logo.svg create mode 100644 website/public/favicon.ico create mode 100644 website/public/home/feature-tools.svg create mode 100644 website/public/icons/example-edit-code.svg create mode 100644 website/scripts/clean.mjs create mode 100644 website/src/client/camera-control.ts create mode 100644 website/src/client/docs.ts create mode 100644 website/src/client/examples.ts create mode 100644 website/src/client/home.ts create mode 100644 website/src/client/interaction-guide.ts create mode 100644 website/src/client/playground.ts create mode 100644 website/src/client/render-runtime.d.ts create mode 100644 website/src/client/render-runtime.ts create mode 100644 website/src/client/rendering.ts create mode 100644 website/src/client/split-pane.ts create mode 100644 website/src/client/viewer.ts create mode 100644 website/src/client/workspace-fullscreen.ts create mode 100644 website/src/components/ExamplePreview.astro create mode 100644 website/src/components/ExampleRail.astro create mode 100644 website/src/components/InteractionGuide.astro create mode 100644 website/src/components/PlaygroundShell.astro create mode 100644 website/src/components/ViewerShell.astro create mode 100644 website/src/components/docs/DocPager.astro create mode 100644 website/src/components/docs/DocToc.astro create mode 100644 website/src/components/site/Breadcrumbs.astro create mode 100644 website/src/components/site/Header.astro create mode 100644 website/src/components/site/LanguageSwitch.astro create mode 100644 website/src/components/site/Sidebar.astro create mode 100644 website/src/components/site/ThemeToggle.astro create mode 100644 website/src/content.config.ts create mode 100644 website/src/content/examples/3d-buffer-geometry.json create mode 100644 website/src/content/examples/3d-buffer-geometry.ts create mode 100644 website/src/content/examples/3d-point-light.json create mode 100644 website/src/content/examples/3d-point-light.ts create mode 100644 website/src/content/examples/home-interaction.json create mode 100644 website/src/content/examples/home-interaction.ts create mode 100644 website/src/content/examples/splatting-basic.json create mode 100644 website/src/content/examples/splatting-basic.ts create mode 100644 website/src/content/examples/splatting-lod-stream.json create mode 100644 website/src/content/examples/splatting-lod-stream.ts create mode 100644 website/src/content/examples/walk-demo.json create mode 100644 website/src/content/examples/walk-demo.ts create mode 100644 website/src/content/manual/assets/3dgs-preset-config/compressed-ply-quantization.png create mode 100644 website/src/content/manual/assets/3dgs-preset-config/normalized-falloff-off.png create mode 100644 website/src/content/manual/assets/3dgs-preset-config/normalized-falloff-on.png create mode 100644 website/src/content/manual/assets/3dgs-preset-config/preset-extreme-performance-0-result.png create mode 100644 website/src/content/manual/assets/3dgs-preset-config/preset-extreme-performance-1-result.png create mode 100644 website/src/content/manual/assets/3dgs-preset-config/preset-max-quality-result.png create mode 100644 website/src/content/manual/assets/3dgs-preset-config/preset-performance-first-result.png create mode 100644 website/src/content/manual/assets/3dgs-preset-config/preset-quality-first-result.png create mode 100644 website/src/content/manual/assets/basic-concepts/geometry-attributes.png create mode 100644 website/src/content/manual/assets/basic-concepts/material-types.png create mode 100644 website/src/content/manual/assets/basic-concepts/object3d-hierarchy.png create mode 100644 website/src/content/manual/assets/basic-concepts/orthographic-camera.png create mode 100644 website/src/content/manual/assets/basic-concepts/perspective-camera.png create mode 100644 website/src/content/manual/assets/basic-concepts/render-flow.png create mode 100644 website/src/content/manual/assets/basic-concepts/scene-tree.png create mode 100644 website/src/content/manual/assets/basic-concepts/viewport.png create mode 100644 website/src/content/manual/assets/chunk-lod/structure.png create mode 100644 website/src/content/manual/assets/getting-started/example.png create mode 100644 website/src/content/manual/assets/playground/playground.en-US.jpeg create mode 100644 website/src/content/manual/assets/playground/playground.zh-CN.jpeg create mode 100644 website/src/content/manual/assets/viewer/viewer.en-US.jpeg create mode 100644 website/src/content/manual/assets/viewer/viewer.zh-CN.jpeg create mode 100644 website/src/content/manual/en-US/3dgs-preset-config.md create mode 100644 website/src/content/manual/en-US/basic-concepts.md create mode 100644 website/src/content/manual/en-US/chunk-lod.md create mode 100644 website/src/content/manual/en-US/config.md create mode 100644 website/src/content/manual/en-US/getting-started.md create mode 100644 website/src/content/manual/en-US/physics-collision.md create mode 100644 website/src/content/manual/en-US/playground.md create mode 100644 website/src/content/manual/en-US/splat-transform.md create mode 100644 website/src/content/manual/en-US/viewer.md create mode 100644 website/src/content/manual/zh-CN/3dgs-preset-config.md create mode 100644 website/src/content/manual/zh-CN/basic-concepts.md create mode 100644 website/src/content/manual/zh-CN/chunk-lod.md create mode 100644 website/src/content/manual/zh-CN/config.md create mode 100644 website/src/content/manual/zh-CN/getting-started.md create mode 100644 website/src/content/manual/zh-CN/physics-collision.md create mode 100644 website/src/content/manual/zh-CN/playground.md create mode 100644 website/src/content/manual/zh-CN/splat-transform.md create mode 100644 website/src/content/manual/zh-CN/viewer.md create mode 100644 website/src/env.d.ts create mode 100644 website/src/i18n/dictionary.ts create mode 100644 website/src/i18n/locales.ts create mode 100644 website/src/i18n/routes.ts create mode 100644 website/src/layouts/BaseLayout.astro create mode 100644 website/src/layouts/DocsLayout.astro create mode 100644 website/src/pages/[lang]/api/[...slug].astro create mode 100644 website/src/pages/[lang]/api/index.astro create mode 100644 website/src/pages/[lang]/examples/[slug].astro create mode 100644 website/src/pages/[lang]/examples/index.astro create mode 100644 website/src/pages/[lang]/index.astro create mode 100644 website/src/pages/[lang]/manual/[...slug].astro create mode 100644 website/src/pages/[lang]/manual/index.astro create mode 100644 website/src/pages/[lang]/playground.astro create mode 100644 website/src/pages/[lang]/viewer.astro create mode 100644 website/src/pages/index.astro create mode 100644 website/src/styles/docs.css create mode 100644 website/src/styles/examples.css create mode 100644 website/src/styles/global.css create mode 100644 website/src/styles/home.css create mode 100644 website/src/styles/playground.css create mode 100644 website/src/styles/site.css create mode 100644 website/src/styles/theme.css create mode 100644 website/src/styles/viewer.css create mode 100644 website/src/utils/api.ts create mode 100644 website/src/utils/examples.ts create mode 100644 website/src/utils/manual-assets.js create mode 100644 website/src/utils/manual.ts create mode 100644 website/src/utils/navigation.ts create mode 100644 website/tsconfig.json 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 0000000000000000000000000000000000000000..a59a255bb4e42d27415b80dc374a533f91665839 GIT binary patch literal 16958 zcmeHO30PIvwcctHU)r>`Xi6U1E-8;&u7!ofY@^?_&lYGw_&!#|%7X;4uSVO$I8p z)mfF!K31-!#!59*S@C`qR3^Z7^;XziZcYxs; zCgT5QnTG0*Bzt%KDu3tZpXVul_kt(J?vV4H@i04 z$0kR+*~CB_yD`wt=EwTkRPP|09vHZHBM;$@L?tCQ>SW1k476CYp^=FH*-D-L|6Q)B zcDQuEN@|(jfyPRE^UC@_PyhDPJcaI&{_hXZ&93dA7+ZDkL+ur8tgD@kclWKEr1mC8 zd;Ty!(EjHeLziBiA0K@NbC=n|U?)R$5kqU$s{6Uu2m2h=+3=AMS%;+|tJc<9Uu&rK zThhVNN}YWz6f)~NcQ+Nw zKYM+o>y;a0Jzi78ms%$KnmcdwHl^JfzHGHH-v5_{f%b1tU+-c-)%qH;Dt!$!I$NPP`y9r4n=v+a1tV9xFgV_W z!O6br!D|D*7?|u|HU62tW;Wef`Q+TlCH1-CPCnaHkD1O=%(hFg&{Zv69PVgZ9O^tK zy;SpoP@emP2`n*LN+Mf0OFSeVV(p2EtA)+r=*U9N_p&x?6IN+txVFi{;Af5I`i>2z zdVRGeCPP}C25CnrmIqn|X>U`n&|DTS%fFy5O^pB5TvYs1LR=)9 zkBwwY{yt1pA2c=Zn~N?-D>mR{!l-6W!INxBZi$)7Wboses zq9h$RFIV8kl}cRis>ZeMT1@mdV7$KtV?*uLW5b=Vj|{f09)C|0{s z*!2)Ub~WS-yAgPj^?10mWlv8A7Z)b=qwDVOY}nO>4IZ~+eMcSGWfwC0!$0V-GuLgi zF|BE_G)9v-#ksvXhN66MvoZto?M0YvEyPS)5pH&r;Krp2OkE)!NT-wiO^Fi&EiaR; z8o#WilF6E@o)+3F4Ta_s2<7Pza-twj3&z~JK-@?O#&kv;pRdg26a&NoSsiI4HwBBy zu~+6}BNFE#Lu_XvgB7O31KuF~&ku(AKD`v;vQ893U4R?w3_Q&mj@z=-U?n!;?7D8) z-S!*(M;(6Jb;NepB}c2M4m*p9HfuAq*_ff*!vPbCfmo~}4K`$Au|5;?4cWNWl!IB) z=1iLe(-faKu2kWAcdgTn-o|g;=xMlb-F^B-v{W!OG_2vR<%+yfa*LDR_=O0}MtWkv z;|RKr*x<@hJM? zz82tRJ?`tNJ??XI�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 dD0kH1ikA?2@gN literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..032b156915b125e72b5b5ed61df7091d4ff75987 GIT binary patch literal 12324 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 DYhK_5 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a21135ba98115c5f8b4a53ecf92d24db7fad89a5 GIT binary patch literal 27541 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..54e6f2c3e8b6afc0e0a842e4562401da5cac415d GIT binary patch literal 26306 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

v%*>oVTCUg(AO~G4wFfmVHR6P3_<~Vyc=@M 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�j*=eh7l~B@wK0;nwX{;PqVb=|j&W1i^3QWRi(Hg>ILH##>zwtl(AN-I0d;j{s@o)dn{^x)G=YO&H zt3@yl!ec#FEkC||`S|(g5Az%JwZ44e30{wfOSbR5`B=(*KVb-^DQ(66>%acX^MP+3 z0#yV{1&&U$JIaRQN=jR4t3%@01;{3a?ABVUrO1<=rP>_GBB*3OC077tY9q3$YPHTh zmS#5aGwmfB1*yDMaUCyBu_<9>AqXa8D3c2^L{=cb9OUfeEQE42KorUN%>q3T9zY~q zM=V(`vQtaFJrO)u0%cMQU17y$hnv8ijb-=uGBt(5wj1Q#@!&A+$d=BD+1QGu}cX4CK6cWUk+i2%XMqBfBX=A-|4+?jt2vmbPE^3$8|$W2Z~-Ig;g?8h zxUt?*XtS`|5*>QxSQzgq0|vM>2)%pNwb(KUzQx51Ll>@qQQSd+tw@gI)GP%O7nC$D z_+(^sM`nt%PbiWkQFN&0bfr;E{RG@#mEZwsMocC*0x;u3IzSTel8}P~FI0VQ2jRH4 z&~R{h>%yIcxg^*EJ~ki;kYPf2R-pLpiwqb@I-4K8cQJx zVv0wqo)xl;&%6jondP|%C*(P+ZcuCW~R>YRfPT6>U+!BzGb6B#hz&D~#;w%Gm^;XbQG^L6U_iyALlRuUMzQdjp7AY_kYG(evlbvEK^&GmL~(5V zk(;EASFA#$!xwWVLM&wL651&MhW_4^k~usYf1hdQxxy(VGFoVNNu(oM0G$=n*<;xz zM`AkTZpoL&cC^C{lE?%tc!1@grDnG%;STLsAw}SD0!rs?7ob%Tk&sRxYYvAkB-}DJ zzynvZGOe(;@d@e(Lx-=%`ze z<7Uw@j{szoP1a3nHONObk}<$CYUCbIhH-H|0bgY4vsGt0X zVWF~*H@?ouXEHNc3}eDXU?WKNU>_W$z>DW9C>yszB+~-wIL<-{?YI9MFGq#MII!Ps zqvt|iMgEG_tl1II{Df;hgqURT1T$kS6VJ@0&`kY=m2pFT5CEIlXmQXRD0(zKm zXf1Q`N;$*4XvBJOTP}%?B;^r`k919g0_Ax$1G@Mx=d#{u)>Z2 z;_m^4IPBO+3Zl3e1S=dF&1^S2pgD#C%jt4k-EM&PL@x+eja&O|6I6jUX#x~i@fC$i z5lVoJEgqK1kS(+myWZR-52#(QK^G^rB@HZT+cP<=LzJWa zhH@wzH_4HYgB&9+pfi!)qcIi+WC1!gvLw==Y-rd5!sDt7+P#Q@0uy5+Iy?~+Mz|sn z0Yr&l0QEQcUI9?P0Gx70vnJqzCYi~FS1{%#BI_RL1{rnWUdW`oD33`CE_@-=aSv;a zc{niA)PPs0b)p0rOirPZ@g$l9fH~U84FO}5?cRv6%yES3 zz1oOO4UmaJ+Qo~SGkC(a%C=G}d=!;s<5SR*+LF$Vg(QQ*_O~{K`0rg!n^$Tg;abHW z6#iSol*n?bj!uI^B)}0S6#`CGdv=WYvBm4ia-BirAkfN-lE`Rq88-mRzG_|92cA-O z2TFT~>P#&>T^nvDhR4VUD+6QW&P`v4g)B3%A*jhkr;+ZIpc}H*_{aQGo zSW#7C`Q3m-G9SE2Kx!GlY z8*P5vAu1ATdB65+Gm;R%^=#;bDw1k6XgWQ?nx_5g-u$H9p+2ZFI|jYheq340KYqEc z2YX6v&1_v9%P$|RY_!6sF4bv9QmfXo%eq!@yH;^*dvOA?eR@J}` z*ka`ol&c1aL33Bh2AJx%Oi4RKHL)YBER-6#c%c!>HBtcl zj(c9zQ^io#3gy0g?|b*R&;6wJ{p4@ESLp2K2j}a$e*E#LKlMWYEKt>86XwF(?#zYjC2D3~ltNsTNAY6jIBySxdwsPBfMjC&Qc-K==nFD&xch z#p|!Fbh)dw7DB11W8}UGf$by(d_=5hW}&JrtXP$%4jefYECt#D8EY})l2V`($kSp3 zL!f2;fh$i87O>JBuz9(q>Ddo?AXU^%*zP&YyV<7(hKHoa%&#(7>_Ur5Cx3AA{Uq7F(Z}^1G#VzJsGV%8n=OjGRlM?CQs(vK$xJtK$TUiY5Y`riBQyF1~b2KAna z<>18<0x*(U-On@yE-z{;moIbGD$^9;Jtu^4nho0Rc`K}N9x&b=>O{TZhUjJ+cScB% zK$o1qghfk0m9lc&kK<$u9$}@B`^u{pv^)YgQkf*Ln9>FbjVj7^HVJ}6-qIXO=?)A z%0(>o!KfnIr55N5W_y71m{g40Q|Lgzp|NUqG z^p{`1?brRN^?dn)^T(s=lU`WEb6cn}&yi9O{cG(*(9YN$A0OCr3GJEjphur33stqHj~tb*yq<5L}G zd9cPW2}eABF$wsJPdaKAsR?M}vp^i7)O7F>?I6v8kf15k9SbOUCL6dA2(=kIW?(X- zZNwdp+zd&GE5jogOmXU6YF~e z3!AyZR%lGW>*7-}4Y_lhz+Bh_lPOp+KYCn?S5+_p*Dm_Gup^{pLQ0G>2$_lKY>Icr zlnPaI2MZbL@f7&ui~tg$IHYaK3=!q06ZK+0(nikPnVdL7@l2MGSQWFn( zkQrCY6PTrDDkihn8ioNv85(0ecOyoM+%wE7<1SWQ7Mfrvb%X0OMaXCtTthe-`vN$E z6*nNr1_>cV$c*(|P-MUn6r^EMK*3Kk&Oucc&E%+=aU-1&XIWyvPw7Ayd8=R-nIyt< zi$WXsHn1-Eh$oIq9v~#8sk=SUMN?8l9v^0U)7`8^= zjU}0j?0Y5&3sQxEbOZun(k(tJfdQ6EViB2t*q3PEW$-~_1Rg!o%-qJ*%i8e?G$4rDAJ7iki_MJm(|n)EFp2zEFdcdp@rL8qBPaN{NA9*2z(1nMM_B9EA7-V{-| z5_~mZRrrjg%FBH?AO)L9sUePCh&XT{XGraiM7R;BI(@K%(3o*EOC~Ue4Zx8$5^BORf-q6(g9Ie6Xal zy^wp^RoK)Lztk=Bhf!4#t6I8y-~H_PdaRGf#|KIkabcm^F!=7I*^q{{S+cNbRNr#R z^yiuwP7nb8U2Y6DUGNUnL_Vc4FdVoXmBimo@SNEyFA*-|DF-r$@f=&?ucoqs)$JlnUAr!392PirABc znUF_w<7JVjG#e#cxWEx@aF5CWAeJ~S)x3$0-HTrw+AM2!-}l~Ky{X1-hI?~{9V`dG zi4ix5YJ=k`EEQ9qg)oD)%dYZbTd(q$kB=|>HM}3Ey9$$XqH|QUJ{P^Km*aIX_nD(p-KUnSI zteeXzg}$KxGf2blh;+(DFU()`7|<$l0qWJ zV~rJ25)lKw1Aq>ZO{Zf^B{f1^=MqM3x|xX9!L&n~poYn0oAJ;+hXQo0NR$0&Y`tS- zl#{bv)FDAY4F<-nTO6jOWE&V*%m!OL%{?K^RAfBQO4*j!!cY@njh8I;awf#}tXPH- zQ&UT6tSo$GTZ@~E41{A~3MQ0BKVPrsx6jQXa(YK12tKAFwyudFC^tY2wy_W`05E5upb*A6 zwk1=?gRZxlC_)b_8?{(4S5dyf4CdgICK@iB>>wo`pPSX7%C?AyU&w0^2C2Mv59LBK zdhn1t13Bdau0=uGl&2dkBqeK=)>Kg?VLjl$H>r(1XkJw@*B#1w z%}6GFi9?TrAu)WYS5k6JVGdP@MOBcm(zMV_vCO7IcvX>y=AJ|eYq{{U2F5bu(VETX z%~NhRiV4oPKx1CjOf|_MNN$L0E5-i$?e~BA*MITLufMAA{o+%E1Fb`{~~y)`0vh? zYa6FpUZ%{FLq4+2d$?vw2)U^i8WWt05UMpItX!h%GE3mY1dFl?2#NW3(abHv3$wY# zH{myQ(Kr!-9A7|PKvgDmRmw7LS9x#F_Jnv;3)A&W(hnXT7e@645&gQdw2phSe&86PRtng(2o{{WKLK_1kv!LAlSXB62n6QCtTR^ zcQ(ZdJ;t&o6JZCQMR(TB=B%-6Y&p)-f2BE0(2JOr*)eX`=BW~`F*kHJ)z8cgC+ajI zxN#3+aRRjDhz+zey--gWsojobLxxd|Kr$4f(cp-au%u>Y+GY{8pxu!@7DRPYc(XzV zwVoMYXoiz;TKFzpMWhLeW7t8YRFwyo$%urvGHb{Wym1h>WkLdq6-%y3;vgtI<7(-s zz-R8j&!}^RP*a9pojAqZ(ZLVf_}%jebi$z3v`j~^8Su}bd03191k;EZ(lkmWk_AM~ zfCfzZJ5f^wJ`~A1+P%8f{1AtuZ1FGhuoJ}$uikOQgdy{gisnP4!c+q7J%N@c+GH!z zOR(cY8(C%94AcUpMtj8ab|iA)kj{(+7Xkw?pur7^X$3Vj7^9q2sCZn6D07G)c!C`p znW<^|K;Vx#G6@X807_~s zjCp4Swue&!DI>KSn{W$>pu|Nb+sL>vDeOW5QWb$`%6(9-2^URSxAL3FYiVrR=I)J?q>Q{Ktl=O2RAhIAb8EI&2)PN|%k}`%XN^H)BJdLIn^ZvK5Q)Ju zvxq}Ag`AWTV{5{tsZGX#;ED*0SZGYTpp{sFf;uzMVU{s0&l%82v1<7ShcZZfy9>U7 zTl+gCW%&N#g0aYl(D=%Lh$dJv>+(_> zcgf^D6Jsw}-HrSu@9-`R)lNn5{(dFpSsr)+I19-{zht(oiZA?P&E^n(Eo7ow#ANsH z`+4ur4RHC(b$xk!tOXY;z~iSTO8R|NDb1`jG++=)X<#QG;^I_gnrLn_$6!iLY-X4@ zx6^5Fb`GpEMYBWCU+<*BwFHJ027t)bo@vdD#91wFzIIbn*qMetQror6uXFT?S(B~7 zP;Bupo`rrRAsfO%h;tblMhCd9SuXAIuxtdP!Clm%IUewjqw#Re2BiszVQk`CRVqgU zGBYsy?TU<;d+4j_Ti#OZR7=x9T5J+ViNTc3+|*vQNFiX*;g!iqZ(pn|qh>-9Nb#0! zoqw3lx~vlL1x!HTFNP)vMZL{ZG0#8Dl6|*3d^$T~=Z=iXhwsMCND(%rVg)afcDa^{ zU!Wfne`;L%@yqq#SJO2w50sDVT5EmGPtp7$y6RfBs;;WFYJuojb-Bte`jl^*M+7l* zEu&mh-{yg}5SYJema|YTvzWhlz~hJRM{yz^kL5=p*Hxs=Z!@b_`9g(|$SiyUuEn@6 zY-d?(T`CqOpTtCfs#U~9s;M(|xrLg23`a1OZfi=DZm!;&8gBlI=WqS{_56JD=lcD+ zpWVG|^1ZGvfAqVb{)oTzzdU~U^5p^^h1JTwBXbO~-QCqM-41^;@=Ca0A)^^h{PFafn>jG&l%JbI3&DYgu(wm92=A z(%uo%!k3XJ-n zC4)DA{sNiFQjxeLZcjC+zTZ2Gof|(~RCIyW70ha&*lmxe<~W6)Jd`&@ zTNjVys`dB)%%uU7$b-p;B$!Y+)>B%3i{)^AC$9GJCxxih1(um;$VX0e8aChO2(S^K zDuW7DHWHJ_Hke`(*2OJA#P(oZv{^RC8y~qN%f`h#4=!J#%cvbFjWReV?9Loo;a~x4 z_6??xj3ro65SeKSG!h6>(P}MLbzPPz4aZpL+#unT`=%7eArmqw3VmHeR{Qm8+*_wg zO$aZQg7taUJ3%FH*BZ{EhfP(fclvC!C3X-=A^_H+FYNh3*j*~y5h{XHKQ2il0?`+x zP{|KwURDiD09}~i#2PJuvKvxpC-j_gae6R>p2a^~G7PamQ3~tn5~P`p#{yY|g}*-p zut)&JPfTn>oDwKS-o~t-{P$uT{0`Lb7T-Ruu}L zwg$n}<-4TJ7ac9f2qaaNnvB~lOu5W=DX4^7?uq?=l0)oO#i_`CpBi2zV%eK_!bYvU zdoUNb$kp!C65)i{`IfF-~084Uw-}lFTdX1&)5CS zFJGUZ_k0Edn}%Kptji)puvpJ^%SX`!E0V zfA(ju{VH8Bl1&cqGVg63HHx&OE&_lP?(MilNoF5S0f1GDBo)aV-Apg6R!9%2P*Wrvv76PfU>bPg=W;O{M<4*iH?l*M zB{OY$@eF8um|Jg;@QI7wp$jOcT7+cd0UlenKTW=(Ez7=K>ndW#KrE_9iXa<<)WX81 zQ`?P*1xSJ%DICj|HIwNSsU^de34A@%DM|XdpSd|B^Nr;BeEs_Q_4E1de!f1xy;$yj zcW>&#u^aMWW&%=JKq-!(&ENsoe0s63>L7xdizH*tC6TSA?gE9cNy#9rYQP{D=oP#& zpwxq55S3UG1)zty5F~0mT;w2+eEfhhN`WJxDJp@$SaJ!~OvfChh~Sw6OaRM3W+WEP z)1o5?g*P)42gl&*%y0_Knw3p}b6GiaEas}{Y=_1EVt^9c5SBX38W0PSgeO{LgFr;l z?2e7brhWj9EEq6l;5voI*JxPc9xA}fbGO}$NMYT9#{=BR^#-(Nd;(>^kWSQGnkDC5FlnfD_Fyp*?5A* zf(!mu5!O^trFVl|T-0|2|9_0;<17}=PCIlu)R-YoCN6XbF0p@$Y?}gtjKK)GHKv+1 zi;UJRjziEH6;}hSa;8WoOHZ5IcIY$5XWOpwZ~XLWS*h%N~AzM)*PB*Zh|!sk?ifa%`5%_rf`-Z7a$vwfb4;E;IIhaav{0fN+E z3cgqb+|n;6hT3nVcn1&8vmHt8;~jTF&Xm6Fy=T>K#;{9UVx|~n%iTnc`nrwMU;l{ zaFx|PT=0;0$UA$gt_V=czrkb#v-GJF8V958-2oO#O(yLV?m+WHiQ?kM1sG_QDT2cw zjT5duZY^b8#m6{s$cuQ0h!_QH`WUqu$Q5`QA`I9Dst#46LhL!mD!mi__Pxtihsuxt ze4D~#VPkHU@sz48!&A1RDoyM88AWlk;1RXh2Fx0}1aZ52J|p)oOn1N@%EW%+67&0V z@4ks`UXwsK;0@(q5{f(o+-ZPzFeHyxkMa&&od$!hS%Gba2;>C`g9Tb>63|sHxUbSG zuQ5>Zv9_u%^F5irlIBm=`+WUOE&j4v*BU7RgaEo-ys#xkB1J{fSVfc`(r}F4xQWxY z-&I0yNv7L?k)8}lT21!OrY6Uxu?QG*t!SDtN_V5@=PlD%sU_^VdxE!`cyc=w=M{j- zss;>*m|%kJknw4RpAn_dmf;DU*=TT$OaxD%OQ=3}j_}}CSwWV5rI?8a9MkcsHbbHVG&g=@=l2sV?LBcWKw zDbu3~1}<~eA!7^ncDW>t)!h)_HNK%SCBBn369LqE-Tk7TeQ(lg-M1nRS@%Beb*J3o z?_&saywfxc_UiO~hh7au^J4{ngXGfna4)4M8bbIQ;qsCnwJN15dHQvgV?$Fe3+1)+ zSp6`c5ne@7Dw5*Y;l(^MWjj57T&zScQv^rYU+a2suQmPU8XR2<;0Hf>JubA5`dIZ? z>w2I3sx^Tyy7<}ku^#hd_WWpG3r_2@xUs)3KRzB8Znf6J|8aS_&&Y;#}H zBwMEF2Kn&=q$jeg%7nfiRqMJOx6*tpPsy(m4WBax@2Z)$Woy<9?`!0Rw1UEP!dl{4 zmt`8B*x(r-L1ZkrM7)90g)a|Nab8T=@MbJ~6$KfB*hwCG7?8Q05dp`DX3fr>Qy7Ph z?QocQ9u{8_FvpcsB}W5p6>*qx#cGFQj+NA?vWOf$eX>Ci68sb_Mc9NI*@s3;$muLp zSTT+yiNMsmy&V^sU_?>S83JY1lKCfYmyATF#a0#7BFfP_GnkfP$O6i52O}Wl4)HJx zj-L6RWFg6^&VaUz1N_ICyF}VpS}HQa6-$h)&x9EWt{!6N`cyvd!aMyw`d6F~@=O7-7sOx^3xa6xEgNse*Pu_-s3@WDZLv$xLFw@wLl{E92?_uJ5CBO; zK~%@a!!fL?P!2>@EenP!+^PsZRkl3xf+>d6V~h-;F{CE*p6pu4!0puNB`+qjgr}rs z(~IC$wce2BNole%@@PI%?2uQ_R^6Nb*u#$KEM9*^$*X_ zKm7Xjm(S;uUnF0j&;7bz_mi)A-+uW88lT^uYh6EmJoL$StSTawp-_AkEJ8I@YOPvu z$kh7&KmT9dXE(_}1=nu$WDTp-yqy7X=t$~7r9Bx{xNYD23rX1&C}<*WE87q1ji zxn)3k)XjN>6OS*(ar0QA=&6GnxJXSM(w2-9IrSDB*;7z5ZIV&=0$0r!yw~$}->lg2 z*Xud`^WM+hugnk7`HmQiih}|;Jed#%iNgk;DXhFoibKKWZPdN#?03g*hZjx6K4nYI z{PK%O~9h%8$zg*vdDFf(v-p(GX)F*_G}g%u8HzVl4nm4JzjVp}0PYq1D7=Ck6q zEkGbFF$xX(_ofd~td)V1IX2j0F!88^Wk`zQ$UObA)Y%cBYK>2|>*mY5F6Q z1eT9x&Jz>S%q_a0VPM6CXgshAEZ;16c$G0bWyC_r>)1d-2r%t75wLroA+cGY7Qyse z^SlYAcWhhT8h`OX;di`kU<5p+#E?zlYzB*NkhW|<@npnG{{Am4%$^b3qM1n@D9j~R zL%mIynL8Q)2SFe*7wIsa8H0EpMFx^3>N*dhU}al#NeoPRJ7CSphv+OtH!<*mv|@ZG z7TH9awdvC8n=dU*8HP`xsi@4i zry?7dOGZjRXvtIn?0MmE4=^ubL0BBC*>af%Fw5_KjVEG^QJKI31|j+pi>XY9OBEB% z++r9DK}WneS$fvim@xB1#hxvm6(_Gow{XP5=0)mQA{v&-Y@{8z#Xu^dp=3rcOV~2Q zf(a1x?<+IVaWUg@qejglFb=hv5C+qhiO6MzDI2KLAp7*1#ukt1)QX3a;DC%HW^J6t z8)twLm}Ydqm2EykJh38~e`3zyrhzllAm754C<;3(4%>9ysS8|?yD3>B4>9<2FpbAKJ>I2PDcS4#*i8d1trTaV?A!znENe1h(tJ}!eKzA;P4I(nV^=sh~$<$ zpUVnW^CT#6l2I(mf?w&>NTU!9{V+JvN!eWtlGTBJgF-lC&s9jlW`$mw+;^`(Ni6fI2sgs3hti*R?^itsv44@e`Ow0TD#BDiWmva#=Eq$ElD%X>c=+hvFcPyeLJdD-WV>P{T(gu|i$*ZX z;YuJH*2eIJrX1A5nHZr0!Be`MM|DfMC`+r#vLw74Q$YZLRKz)f2v-u+jm0fM9gET- zjP^cx;@N5JAh83Q2ohT^RY|4L%k~hg8CO=31bcYjcLR=9O~Z`}ii#7JOXA68ybTG& zFeEfd5fmdsiO3>qjPz|}rktoTtgxW`ZlP0UZv2-Lx<8C|UrKwMi3>u!5UHYv# zGII+WlJ~9VmCN5yQ*x_dL1_yc>WyiM`1N)o14AK0N;LCiG*miirn@O6VvIA5jeyU@ z!QtqT!k?7gWK0Rd{5?bBcVZ?6C{Ph(1JQG_fE0ggh(Ac!0Qfd-H-B-nD}t+Qj?y!| z+3lt$XuEo+qu!hmX4r3dmff3EvDFsc4baQDJ}sAtK@PE-NiIk9QR=AD4%d&Ck%k`{{Q-zWTHyQQ zajm8GD0i<#+{feN<8fUNeki}be0i+vgOzNHjwAKOa#gA!aSN z1os^#M;i=n*;Wl5y5*{JX&Q5Ym?Oa`K!M~A=YW%IB4>v^T@S+zYF>qf;*_z*NfCaM zr8dB|BqSP>x6M z#}&bcpc;C%YpzD=bn^%hjh8gfu`OedUSN(_3JJ``f>{?|V3-S+lxVh9X4mOb=07hK z9|U-pML0{vI6*9%IVGX4;eZD{N%FH}(&I~Q1+wtQcxuJ6QIgXfcUFn*8R^;qP^2HX zf|TmCuA}m5c8aN>%3g^C$}phpfT$aNg4aB>o5|v-Te?LAT)e{r25y5fwBb-ow%nl; zmEfs0H?W{1i_AP}zA-s$Fweq?V%jQn*m3m@61)-Mjw4Z*@YeXhyM#Fgn9G>7@Q$au$LC@3r_`W&kI_$MiwX15oD!~E;7bYf!h2jYDZ-BT#LO^bU zLH0 zKIV4%eBXX(?P*Ut^!~nMd}GxiL}ic=P999v#Wv@zlkFb6=IxImAtwmoYK07S77a#|wvWw90} zlCVwo{eInf8C4KarBY+DMNHMf%q~@lHp!&-JAQ7bZ`En?Zr!eB(OA@ zE!XYoX{{T{Kwirxf|AP;&I-NJ7I~5xRw1Z`14fx(IR~bYp7|^%Q;^uY;5kQO6X|Xv zUiR*m`IsaqqGOeFU)O%6#@B0aevzL4R{wHmy;&;$_T}ZW7Q#HIvg#6||lxu8`Uh=-IyGoqA70Rd>4*B{P^ z$}vr|8|YCRV43ZZMi<~9U9;3_r4b0=aYNWK3JWU%p9>-^&0t${fD_%!frtPfC?OQ) zRJ;u>3XzdVJ_M~}Y0GFMg{I*{t2F=ARYqa)&(eekz>g(X1IL;*Gl|gxpEjNjzv+cs zGzuorL@b$q!XduKHO!x67?_&FA{sgzGGSe3U@{W3Ksf`qRTE9&AvnJz1PU@DF*neM zxyfiMo|b%a)T3FrWlBKwdc zqtl~7!vumLC6wuT?%+{sv9baHemU?-ahVI@Eay&S3^-W9yFMui0$Y15B;$gmz`_7t z6kIe+4D|%)GBi*im@ASB-GkVyG87sSJT^20#Wc)DtWV84f-0OdWr@?V1>UGeft-yj zL8E~MU&FH`3UESMPs)>u`tsd~YAhMK?Y6PAh-5mL78@zW8Vsiz>_|9C3^ENJ z!UATd<9fuAd1Q_d$A**39u@067WB9nSx(m-AS!!-oDxmU;~70>%}q{I{quAjdpdbK z?#xZL-m)!#R4P#$O)zB`2t0&003ZzHInTm59U3oSLsjR4if|?h^v&bSlfevQ5#mxm zg~tHcmX5;-R;#tfMmh9i&(yO&V0b(mk8U2L!bOM9MMPD8f=twn4-~B;5I7UEX+jei zrifrY))We%2o`Uq2~QIq>1KFEV#?T+%#ku}+=&QaK!Xnvr8E=jFZLZQAe1tmxS+%b zf*^Q!l%SX;u9%Zt@Rb%bj6I2rT3Bf)=w@Cx3Nk3E1jc+pJ4?}+=;6WCM|hA}yGRe? zL(_;x7;B_D%?*s%^lrr*Lk1jqk+uZb=@>~hzDWm6&!AEh2k)4$k7ePZ`A)*CmT!lI z6kK78)oiQ?Et~?=aI}a5v=|}b>X>6v{1gS!2nzOU(M);L^G(wpNk=ymd7^p@;SZOF zkzj1n3%_%UkZ>fUXc^0zCFxC4WqfcypfaodWu@LmYNT|r`EwAAl$Q%zs88>Dm z&J)`toW{F^nF&IiD*yr@9T(Nvx438&vD+?hu#^#d0CEU6w5xQPa`B24FtoaxztKW7FokE4Ih=z&29dfpD zq@=GX+pqVlJK@jRdxu{SUh&3BYdd@A;@tMW=KpY{nm>3sk|^0is#uNv)qDPhpP*l_ zVL@<5w9hTvD1EsJ(KT6YI)Wy24r_IXD4a8bRT;-A7 z+Bg1GuDYofVMz9sIz8RIo}c*X`LlPQef8bXzx(BvUwr4yciugH_U8HB zo7?AKy!q_&r`y}L-rU}OaeID#<_Cdw^V2)Oeanv7=xkVJRo(2{GwI$|pqxjd-hB2JWAm;i$^Zt)C1+eNyF_bCTq<(~DMvrFls( z*hrCJ9HgDNZ`l6W&Ada9@AdSC`UZ^S7t zo-JJL4z0V>$n!%Ld%bHCgf8^Vs0xjU^|=9HAc;3W|!Sn`TX zB`(q~OX7|meq^>h;*m{k7k7(JWZHDNu?Yj`CCX(`_}f$m1WqZ=L?DiW(nUzfyNS3U zD{BS;;pXxhb`z?yh!(HlAC9nX^4JZo%%=om{1Pf5czDRdvZn2jjkSh<(Ys|YP_=H$ zjM${WZu@$D?Y)U%@7>*VIL{U`PvbFrTvKS1;|`L}MISX+_r5lE*FF16$c>0ZRSA!` zo+iW)GQLnuZ+Kj!=OPNxd6A{%suFjN$ZO*KFXX{nZyd0@{6O#K(`WCiFa8VhzV5Hr zzTf+`=6}K9{_^tSx_EGP-|L&NUvhi8Z+@}9*IIemJY~11C9U0@X)&v0=OWi~>3og* z$xnU;;a)P^Yb-b{Vf%=OlOalH5w6e@m)VrIC63#!Tsx^-OAE;4v#7^HXAqjr#gEHs z%LT2qq@oB^D7-E@7Svn_gaav2x{!!A`Z-*nSW)4T8$wp-jGUX@VvQ+5W6imAFTj?& zTRu*XQN^B6RU%a-Kn_rrm=J;2)Cw9T;+chZOiPQfvGxq9(qX7o#iX`NXd3i+0ax9Wr>Ud3lH;vxymBY&jnGe(Qb1?^2#6W@0*ezu7^{gD7sdzbNsQI;M!X=w3@=-Xqv1Wb%NSVD<31|2 zjAs1cf;o1YJjQrfQ4(sOLR2z_LM|gY7vV#q(SRK?NuDU_Eim0W>BXN$g#(BPo(V2C zFv;g44JPmfK_(2u2qW49O8pcW9WevOUS}y03SueNNWm}!`P_*0U}R zIfWJI0fdU>0mU0`5c15yBUHg^cH4tM798ss14dcg6GS7V;*GP>CbPzfg>^{r8{s#i zSMj(-tMMTu#G?IJMjiu*6!ucZ8rngPLR9|9CXl%SOJJm5bx~Q<(nZ3ANO>2xYWxuR zZqsC54}gcdEH~;A$v7@qYHM(@r3na9W;#Mp&HI%>C`~3B91aC%VS*!kqXkb^hH~mM zTO?V$lp!-SQUZ&W4+HjsuCvZlu6QDg#!b-fIg# zriq(%44n$|B2?SZMMz^YwT6PuXt~g^phsazF4;hUVi1ii2xh`yCt{Kx38W8=gb~pwf<>7^n5i^GvOZmsAfeNUOci)D@xo~vL79T^zLsrn1yXk2 zf|D+cSXlfsq8Sm5TQNTpf|e;z)Q8=M!zYNrgtch#nOU(0$wZCZ*s&WTevl;3?s!(^ zHZ!j=n(?CFDUuIiDZ2t*NLgXl+a1>@wCP>Fec@u|ri z1{6B}9GTgO&P2l_;K)6#8y2}+s4k;MDF+WtWLCvknhuka%Vv&R6b>88opCzduqe{Q zei-FL5F{1?VH~ME6CG*UjTDJY0pWu@BRQg!TQ0%Ft+cUH%ohaj`G{k^V$^#P^==Ub z6VFCga8-gqI44A&UI z(Q`o+;e%s?i8bCIjzqv*h$cJ=SA+uwL)mDl$PRDxP=km%k{LATnrNct^7P;Uw8jh+ zbDz2BVK&qx3zUbP`9ka90tv3>^8jQF&P%EsKs$yQAEM!31kOuGBsfC6%fh~}DD+H+ zP*G^mhbp?2E7xw|=|NE<#n_0o2m_=j46i1{GILfl9fbjewI9goAsLC>o(&PrQ^O`z z%#S4)iGT)Z5n;*v6DEvYuKGV*T|-;MdKVV!0;MQm7Z?(=4tsR zd%xWK;$OnMr68?5J?AYyI)C~6)px%5&i6n6>ieI6_W9eV=Xald{_eY9y?ghDpO@c! z{>Af~ch6sb=k1$!Z=RlUNhY>)W30%xUcASg(ygqziQLq@fV+jDYk7XB@@=hkTSfe< zR%tF*Av8lw{nT&Yy#3x+pa0;iuYTj#zW3uF|Hg0p+K+zx=YIU>|M`FJN5B5VU;E(? zzWd!TKmX$0o44!sL^fN!O|dtJf-`dM`zbs>rR=_cc)ea<_MLXc0F%DYhPYJa*)%1L zi)bw~b9pTk)x1wP)Ry|X_Up^NRBzu>#V1Uy-94sNC4x@NVZsH&r}u3LEOss3sz~~_ zRxK+f*&)+nYo$;GMC>pOEa`0nL1>jMacozarZSFfY2hc?ZM(~AVOb@#)5oIOBpf!v zn@N)^&W0ZBPAB+0M|j?7v&K3Q>KKXvCoplzRVHPGSg}IZUV=o1wV@QvcHsw1cW=d3 z*}T~3SyUJu$%a4+!R5nKr4<$A-4Ta}h=Hg;gD2kOJmD{~vMnTlGyfS3 zDAtRojS8?qsj8>TGxK;7I{<4p%)7H`PGCjk$xRr}_lb+^ln8hw4;M#04)>JT&$*z|c*{zUCBS&Kr(BB@K4)j*f>56BM2E z|6+?|p~HDjMCGy#yH_)2q%51a5^KW%fgM~yT2zt3ES&VIl6Z9Xb^qvVeSZ7F7w>-K z?VBI4sjF^pp5DFX_3-7px1YTmCxI(_Us2ReNlF@req@WIY8hD+5^F2>*Y3Wqttwtb zBTw_dso`QZRUiruEtzo6M3k$r3p^PRyDGcbAS^`vaLB+RsWN*d6KjOcHf>ZC032%J z!DA_#((+Lk$*6-=>S-d>DFaw*RToe$VPbM-Nj%JKq5{+qP)mB^VM35nM}3h?G~}AF z6niXy__U{|r^E!1zQ~t}q8c7qo>SJ0MO$?XOrn8@mcmkoz!=Q9z}LbYU3PdUX1Cbn zQ41yUnEdfijo@y8uxm!9`|93|q5-D-{Sz2vARs81Z?ep`~o2Tcu z&*!sl^OJxJLhly{_j6Ok=2Goqvv4F zPJ)Ejj#T0!%%-}Ma5erI;1oBrCb>2nMW7^v#OYOr1~Y3J4iP8!?8pfFP~lh1m>(^p zi~y&er~s;=Ay#4o*m4yI&<{lMw~K{}K#)jSa}xl)Eo5}z&)nJKl<^d{E+hZ|5CBO; zK~%D13_>=>oq56`EAs@X+TchLBNZ%Zp@e2$Cd?@Gi8vEnvzIvzD_u!bf{4DQ7QPy9 z1B7ZE^U>8%bzFQyL63jeU}2IbfP}hpvO1mlkTv17Eh%(oq9LVo$qx=JXWev!P7szQ zgOdn^9E4n#JjsL)f++2C4m)rF69f6p<@pK&LS)2Lm_=?f^zbCa7S+gcQI_5Aux7gD zm{E`a{y{Oq6H~ILm0JKNBbyqV${ZDH2sB<*HY!PmN=y-;1R}`@H#p7G^uw5xh6!>_ zB%mh<2|Xc0F&3(F@L<-1K*@AO;EI(#HA^E6TL)twTVn`Sp8>rz2GB6Z;3XB%DHDE1 zsR@N5&c13E8G$t|G|k?8T1qDr={O4-royRBWaAYl$D76?0zNmxtWA@cImB?Z!$Agz z10lS)j8qU=HlBKv1SsZ0zz}R_$|@`ez_f(Sno?sU^9q9pDgDvVi6_Xku@Z}ROhNQ$ z@HUP$K*~=vj%L?JAm`{dYFjXvEg$*`nK(SCherJ{Z;WGfz!eUKh=tsZ9we&pbT$MC zVH^gv<~nL@#?l@)P$Hz4PN#;^%vbO;^P+53BOm-oa7~Z_G#)F0vB_itKr&^8lQ>L+ zhF(%BU?f|bfGI*;>#R-F7|8r9gUEOiD;=F4(Yicgj&i(?4Y#Z|Zo|-05pseAnI