From 21e85a99b85744647fc78085ead9e3908bdd6eb5 Mon Sep 17 00:00:00 2001 From: Glavin Wiechert Date: Wed, 3 Dec 2025 20:54:39 -0400 Subject: [PATCH 1/6] Implement Playwright and Vitest testing framework integration with E2E tests for frustum culling, LOD switching, raycasting, and rendering. Update package dependencies and configurations for testing. Add test setup utilities and initial test cases for dynamic capacity and visibility features. --- .gitignore | 4 + package-lock.json | 1286 +++++++++++------------ package.json | 6 +- playwright.config.ts | 29 + tests/e2e/frustum-culling.spec.ts | 452 ++++++++ tests/e2e/lod-switching.spec.ts | 440 ++++++++ tests/e2e/raycasting.spec.ts | 138 +++ tests/e2e/rendering.spec.ts | 685 ++++++++++++ tests/features/bvh.test.ts | 306 ++++++ tests/features/dynamic-capacity.test.ts | 260 +++++ tests/features/frustum-culling.test.ts | 231 ++++ tests/features/lod.test.ts | 285 +++++ tests/features/sorting.test.ts | 251 +++++ tests/features/visibility.test.ts | 349 ++++++ tests/fixtures/test-scene.html | 150 +++ tests/setup.ts | 109 ++ tsconfig.test.json | 25 + vitest.config.ts | 28 + 18 files changed, 4351 insertions(+), 683 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/frustum-culling.spec.ts create mode 100644 tests/e2e/lod-switching.spec.ts create mode 100644 tests/e2e/raycasting.spec.ts create mode 100644 tests/e2e/rendering.spec.ts create mode 100644 tests/features/bvh.test.ts create mode 100644 tests/features/dynamic-capacity.test.ts create mode 100644 tests/features/frustum-culling.test.ts create mode 100644 tests/features/lod.test.ts create mode 100644 tests/features/sorting.test.ts create mode 100644 tests/features/visibility.test.ts create mode 100644 tests/fixtures/test-scene.html create mode 100644 tests/setup.ts create mode 100644 tsconfig.test.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index f06235c..65620a8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ node_modules dist + +# Tests +.last-run.json +playwright-report/ diff --git a/package-lock.json b/package-lock.json index 9c005b1..19ddd9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,15 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.57.0", "@stylistic/eslint-plugin": "^5.6.1", "@three.ez/main": "^0.5.11", "@types/three": "^0.181.0", + "@vitest/coverage-v8": "^4.0.15", "eslint": "^9.39.1", + "happy-dom": "^20.0.11", "meshoptimizer": "^0.25.0", + "playwright": "^1.57.0", "simplex-noise": "^4.0.3", "three-hex-tiling": "^0.1.5", "typescript": "^5.9.3", @@ -25,397 +29,70 @@ "vite": "^7.2.6", "vite-plugin-externalize-deps": "^0.10.0", "vite-plugin-glsl": "^1.5.5", - "vite-plugin-static-copy": "^3.1.4" + "vite-plugin-static-copy": "^3.1.4", + "vitest": "^4.0.15" }, "peerDependencies": { "three": ">=0.159.0" } }, - "node_modules/@dimforge/rapier3d-compat": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", - "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", - "cpu": [ - "s390x" - ], + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/parser": { + "version": "7.28.5", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/types": { + "version": "7.28.5", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", - "cpu": [ - "x64" - ], + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", - "cpu": [ - "x64" - ], + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } + "license": "Apache-2.0" }, - "node_modules/@esbuild/win32-arm64": { + "node_modules/@esbuild/darwin-arm64": { "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], @@ -423,41 +100,7 @@ "license": "MIT", "optional": true, "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" + "darwin" ], "engines": { "node": ">=18" @@ -468,7 +111,6 @@ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, - "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -484,8 +126,6 @@ }, "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { @@ -497,8 +137,6 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, "license": "MIT", "engines": { @@ -510,7 +148,6 @@ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", @@ -525,7 +162,6 @@ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/core": "^0.17.0" }, @@ -538,7 +174,6 @@ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -548,8 +183,6 @@ }, "node_modules/@eslint/eslintrc": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -575,7 +208,6 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -588,7 +220,6 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -598,7 +229,6 @@ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" @@ -609,8 +239,6 @@ }, "node_modules/@humanfs/core": { "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -619,8 +247,6 @@ }, "node_modules/@humanfs/node": { "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -633,8 +259,6 @@ }, "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -647,8 +271,6 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -661,8 +283,6 @@ }, "node_modules/@humanwhocodes/retry": { "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -673,10 +293,45 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/pluginutils": { "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", "dev": true, "license": "MIT", "optional": true, @@ -706,7 +361,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -720,7 +374,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -734,7 +387,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -748,7 +400,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -762,7 +413,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -776,7 +426,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -790,7 +439,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -804,7 +452,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -818,7 +465,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -832,7 +478,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -846,7 +491,6 @@ "loong64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -860,7 +504,6 @@ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -874,7 +517,6 @@ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -888,7 +530,6 @@ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -902,7 +543,6 @@ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -916,7 +556,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -930,7 +569,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -944,7 +582,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openharmony" @@ -958,7 +595,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -972,7 +608,6 @@ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -986,7 +621,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -1000,18 +634,21 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, "node_modules/@stylistic/eslint-plugin": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.6.1.tgz", "integrity": "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.47.0", @@ -1032,7 +669,6 @@ "resolved": "https://registry.npmjs.org/@three.ez/asset-manager/-/asset-manager-0.0.1.tgz", "integrity": "sha512-29t73BMg9AsH7FIUyJZAP8PjticjK/t3g+NW1TV50vh1/LlVD2qKBQvbD5mGPYNi7QK4lH+xgukJXGNa6kfj0w==", "dev": true, - "license": "MIT", "peerDependencies": { "three": ">=0.159.0" } @@ -1042,7 +678,6 @@ "resolved": "https://registry.npmjs.org/@three.ez/main/-/main-0.5.11.tgz", "integrity": "sha512-aLOPLSTX3v9kPJgrD9NbmEdSAYdR2SssU3OXGChRo6q4eP188IOD61QHztjP922/r0oZ5W6c2chawyE1neLf6A==", "dev": true, - "license": "MIT", "dependencies": { "@three.ez/asset-manager": "^0.0.1" }, @@ -1052,8 +687,20 @@ }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", - "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", "dev": true, "license": "MIT" }, @@ -1061,20 +708,24 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.19.25", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } }, "node_modules/@types/stats.js": { "version": "0.17.3", - "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", - "integrity": "sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==", "dev": true, "license": "MIT" }, @@ -1083,7 +734,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.181.0.tgz", "integrity": "sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==", "dev": true, - "license": "MIT", "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -1098,13 +748,15 @@ "version": "0.22.0", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/webxr": { "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.21.tgz", - "integrity": "sha512-geZIAtLzjGmgY2JUi6VxXdCrTb99A7yP49lxLr2Nm/uIK0PkkxcEi4OGhoGDO4pxCf3JwGz2GiJL2Ej4K2bKaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", "dev": true, "license": "MIT" }, @@ -1113,7 +765,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.1", @@ -1143,7 +794,6 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } @@ -1153,7 +803,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -1178,7 +827,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.48.1", "@typescript-eslint/types": "^8.48.1", @@ -1200,7 +848,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1" @@ -1218,7 +865,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1235,7 +881,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", @@ -1260,7 +905,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1274,7 +918,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/project-service": "8.48.1", "@typescript-eslint/tsconfig-utils": "8.48.1", @@ -1302,7 +945,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -1312,7 +954,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -1328,7 +969,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.48.1", @@ -1347,28 +987,161 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", - "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", + "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.48.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.15.tgz", + "integrity": "sha512-FUJ+1RkpTFW7rQITdgTi93qOCWJobWhBirEPCeXh2SW2wsTlFxy51apDz5gzG+ZEYt/THvWeNmhdAoS9DTwpCw==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.15", + "ast-v8-to-istanbul": "^0.3.8", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.15", + "vitest": "4.0.15" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.15", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.15", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.15", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.15", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.15", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@vitest/pretty-format": "4.0.15", + "tinyrainbow": "^3.0.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" } }, "node_modules/@webgpu/types": { "version": "0.1.54", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.54.tgz", - "integrity": "sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg==", "dev": true, "license": "BSD-3-Clause" }, @@ -1377,7 +1150,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -1387,8 +1159,6 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1397,8 +1167,6 @@ }, "node_modules/ajv": { "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", "dependencies": { @@ -1414,8 +1182,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -1430,8 +1196,6 @@ }, "node_modules/anymatch": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, "license": "ISC", "dependencies": { @@ -1444,8 +1208,6 @@ }, "node_modules/anymatch/node_modules/picomatch": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { @@ -1457,22 +1219,43 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/binary-extensions": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, "license": "MIT", "engines": { @@ -1487,7 +1270,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1495,8 +1277,6 @@ }, "node_modules/braces": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", "dependencies": { @@ -1508,24 +1288,26 @@ }, "node_modules/bvh.js": { "version": "0.0.13", - "resolved": "https://registry.npmjs.org/bvh.js/-/bvh.js-0.0.13.tgz", - "integrity": "sha512-7jVxKGyyATOwEoqFvghXoStJXkkmqf7JIXYEG44eMtMXQoeCwZL2n1z5/kZogFKvCaN7XweS2TKnqdyDrd0DJA==", "license": "MIT" }, "node_modules/callsites": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { @@ -1541,8 +1323,6 @@ }, "node_modules/chokidar": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -1566,8 +1346,6 @@ }, "node_modules/chokidar/node_modules/glob-parent": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { @@ -1579,8 +1357,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1592,8 +1368,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, @@ -1601,13 +1375,10 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -1621,8 +1392,6 @@ }, "node_modules/debug": { "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, "license": "MIT", "dependencies": { @@ -1639,15 +1408,16 @@ }, "node_modules/deep-is": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", "dev": true, "license": "MIT" }, "node_modules/esbuild": { "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1687,8 +1457,6 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -1703,7 +1471,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1763,7 +1530,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -1780,7 +1546,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1793,7 +1558,6 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -1808,8 +1572,6 @@ }, "node_modules/esquery": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1824,7 +1586,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -1834,8 +1595,6 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -1844,8 +1603,6 @@ }, "node_modules/estree-walker": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true, "license": "MIT", "optional": true, @@ -1853,39 +1610,37 @@ }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.2.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -1902,15 +1657,11 @@ }, "node_modules/fflate": { "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1922,8 +1673,6 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", "dependencies": { @@ -1935,8 +1684,6 @@ }, "node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -1952,8 +1699,6 @@ }, "node_modules/flat-cache": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { @@ -1966,17 +1711,12 @@ }, "node_modules/flatted": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -1988,8 +1728,6 @@ }, "node_modules/glob-parent": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -2001,8 +1739,6 @@ }, "node_modules/globals": { "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", "engines": { @@ -2016,23 +1752,37 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/happy-dom": { + "version": "20.0.11", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.11.tgz", + "integrity": "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==", "dev": true, - "license": "MIT" + "dependencies": { + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + } }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -2041,8 +1791,6 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2058,8 +1806,6 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -2068,8 +1814,6 @@ }, "node_modules/is-binary-path": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, "license": "MIT", "dependencies": { @@ -2081,8 +1825,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -2091,8 +1833,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -2104,8 +1844,6 @@ }, "node_modules/is-number": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", "engines": { @@ -2114,15 +1852,62 @@ }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, "license": "MIT", "dependencies": { @@ -2134,29 +1919,21 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/keyv": { "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -2165,8 +1942,6 @@ }, "node_modules/levn": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2179,8 +1954,6 @@ }, "node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -2195,24 +1968,52 @@ }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/meshoptimizer": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.25.0.tgz", "integrity": "sha512-ewwuAo3ujPZ7T3Y2oTkEoLlXvNOqnr0cjyAxfv5djXJqwD9QlxDDO0qGtsqB4Z9QUVvhruKXg9q/xfK9I5S1xQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2222,8 +2023,6 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, @@ -2238,7 +2037,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2248,25 +2046,28 @@ }, "node_modules/natural-compare": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/normalize-path": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -2283,8 +2084,6 @@ }, "node_modules/p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2299,8 +2098,6 @@ }, "node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -2315,8 +2112,6 @@ }, "node_modules/p-map": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", - "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", "dev": true, "license": "MIT", "engines": { @@ -2328,8 +2123,6 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2341,8 +2134,6 @@ }, "node_modules/path-exists": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", "engines": { @@ -2351,25 +2142,24 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -2379,6 +2169,49 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2398,7 +2231,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2410,8 +2242,6 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -2420,8 +2250,6 @@ }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -2430,8 +2258,6 @@ }, "node_modules/readdirp": { "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "license": "MIT", "dependencies": { @@ -2443,8 +2269,6 @@ }, "node_modules/readdirp/node_modules/picomatch": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { @@ -2456,8 +2280,6 @@ }, "node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -2469,7 +2291,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, - "license": "MIT", "dependencies": { "@types/estree": "1.0.8" }, @@ -2507,9 +2328,7 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.2", "dev": true, "license": "ISC", "bin": { @@ -2521,8 +2340,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -2534,35 +2351,42 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, "node_modules/simplex-noise": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz", - "integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==", "dev": true, "license": "MIT" }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -2574,8 +2398,6 @@ }, "node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -2586,26 +2408,33 @@ } }, "node_modules/three": { - "version": "0.181.2", - "resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz", - "integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==", + "version": "0.177.0", "license": "MIT", "peer": true }, "node_modules/three-hex-tiling": { "version": "0.1.5", - "resolved": "https://registry.npmjs.org/three-hex-tiling/-/three-hex-tiling-0.1.5.tgz", - "integrity": "sha512-c38BKK3IV/AplEoO0DlUof3e5ikHEKW3XnKkuOt9PqLz7QRoc4ZuYnBqfoaXe3rctLmFztnEiiPRRGSR34mPgA==", "dev": true, "license": "MIT", "peerDependencies": { "three": ">=0.151" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2619,10 +2448,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2634,8 +2469,6 @@ }, "node_modules/ts-api-utils": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, "license": "MIT", "engines": { @@ -2647,8 +2480,6 @@ }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -2663,7 +2494,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2677,7 +2507,6 @@ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/eslint-plugin": "8.48.1", "@typescript-eslint/parser": "8.48.1", @@ -2696,10 +2525,13 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2711,7 +2543,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, - "license": "MIT", "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2786,7 +2617,6 @@ "resolved": "https://registry.npmjs.org/vite-plugin-externalize-deps/-/vite-plugin-externalize-deps-0.10.0.tgz", "integrity": "sha512-eQrtpT/Do7AvDn76l1yL6ZHyXJ+UWH2LaHVqhAes9go54qaAnPZuMbgxcroQ/7WY3ZyetZzYW2quQnDF0DV5qg==", "dev": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/voracious" }, @@ -2799,7 +2629,6 @@ "resolved": "https://registry.npmjs.org/vite-plugin-glsl/-/vite-plugin-glsl-1.5.5.tgz", "integrity": "sha512-6NM2P4JkM+1hNSqMhM4eagX03bmhEoTyrOrk68y3Q6KXfdF73QIuCb6BmRZvwLPgXTCOBM3Zc8gL1WxctYnrUQ==", "dev": true, - "license": "MIT", "engines": { "node": ">= 20.17.0", "npm": ">= 10.8.3" @@ -2823,7 +2652,6 @@ "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz", "integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==", "dev": true, - "license": "MIT", "dependencies": { "chokidar": "^3.6.0", "p-map": "^7.0.3", @@ -2837,10 +2665,93 @@ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/vitest": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", + "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", + "dev": true, + "dependencies": { + "@vitest/expect": "4.0.15", + "@vitest/mocker": "4.0.15", + "@vitest/pretty-format": "4.0.15", + "@vitest/runner": "4.0.15", + "@vitest/snapshot": "4.0.15", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.15", + "@vitest/browser-preview": "4.0.15", + "@vitest/browser-webdriverio": "4.0.15", + "@vitest/ui": "4.0.15", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -2853,10 +2764,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -2865,8 +2789,6 @@ }, "node_modules/yocto-queue": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index f19037a..6169d97 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,11 @@ "start": "vite", "build": "vite build && tsc --build tsconfig.build.json", "lint": "eslint --fix", - "test": "echo todo add tests", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:all": "vitest run && playwright test", "publish-alpha": "npm version prerelease --preid=alpha --git-tag-version false && npm run build && cd dist && npm publish --access public", "publish-patch": "npm version patch --git-tag-version false && npm run build && cd dist && npm publish --access public", "publish-minor": "npm version minor --git-tag-version false && npm run build && cd dist && npm publish --access public", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5473556 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'webkit', + use: { + ...devices['Desktop Safari'], + }, + }, + ], + webServer: { + command: 'npm run start', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); + diff --git a/tests/e2e/frustum-culling.spec.ts b/tests/e2e/frustum-culling.spec.ts new file mode 100644 index 0000000..a1a267d --- /dev/null +++ b/tests/e2e/frustum-culling.spec.ts @@ -0,0 +1,452 @@ +/** + * E2E tests for Frustum Culling + * + * Tests actual WebGL rendering with real camera frustum calculations. + */ + +import { test, expect } from '@playwright/test'; + +test.describe('Frustum Culling E2E', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/fixtures/test-scene.html'); + await page.waitForFunction(() => window.sceneReady === true); + }); + + test('should render instances within camera frustum', async ({ page }) => { + // Create mesh with instances at origin (visible to camera) + await page.evaluate(() => { + window.createTestMesh({ count: 100, spread: 10 }); + }); + + // Wait for render + await page.waitForTimeout(100); + + // Check that some instances are rendered + const renderCount = await page.evaluate(() => window.testMesh.count); + expect(renderCount).toBeGreaterThan(0); + }); + + test('should cull instances outside frustum', async ({ page }) => { + // Create mesh with instances far from camera view + await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 100, spread: 0 }); + + // Move all instances behind the camera + mesh.updateInstances((obj, index) => { + obj.position.set(0, 0, 200); // Behind camera at z=100 + }); + + // Trigger frustum culling + mesh.performFrustumCulling(window.camera); + }); + + await page.waitForTimeout(100); + + // Instances behind camera should be culled + const renderCount = await page.evaluate(() => window.testMesh.count); + expect(renderCount).toBe(0); + }); + + test('should respect perObjectFrustumCulled setting', async ({ page }) => { + await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 50, spread: 1000 }); + mesh.perObjectFrustumCulled = false; + mesh.performFrustumCulling(window.camera); + }); + + await page.waitForTimeout(100); + + // With culling disabled, all instances should be rendered + const renderCount = await page.evaluate(() => window.testMesh.count); + const totalCount = await page.evaluate(() => window.testMesh.instancesCount); + + expect(renderCount).toBe(totalCount); + }); + + test('should update culling when camera moves', async ({ page }) => { + await page.evaluate(() => { + window.createTestMesh({ count: 100, spread: 50 }); + }); + + // Get initial count + const initialCount = await page.evaluate(() => { + window.testMesh.performFrustumCulling(window.camera); + return window.testMesh.count; + }); + + // Move camera far away + await page.evaluate(() => { + window.camera.position.set(0, 0, 10000); + window.camera.updateMatrixWorld(); + window.testMesh.performFrustumCulling(window.camera); + }); + + await page.waitForTimeout(100); + + const farCount = await page.evaluate(() => window.testMesh.count); + + // When camera is far, fewer instances should be visible + expect(farCount).toBeLessThan(initialCount); + }); + + test('should handle hidden instances during culling', async ({ page }) => { + await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 10, spread: 5 }); + + // Hide some instances + mesh.setVisibilityAt(0, false); + mesh.setVisibilityAt(1, false); + mesh.setVisibilityAt(2, false); + + mesh.performFrustumCulling(window.camera); + }); + + await page.waitForTimeout(100); + + const renderCount = await page.evaluate(() => window.testMesh.count); + + // Should have at most 7 rendered (10 - 3 hidden) + expect(renderCount).toBeLessThanOrEqual(7); + }); + + test('should use BVH for culling when available', async ({ page }) => { + await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 100, spread: 50 }); + mesh.computeBVH(); + + // BVH should be created + return mesh.bvh !== null; + }); + + const hasBVH = await page.evaluate(() => window.testMesh.bvh !== null); + expect(hasBVH).toBe(true); + + // Perform culling with BVH + await page.evaluate(() => { + window.testMesh.performFrustumCulling(window.camera); + }); + + const renderCount = await page.evaluate(() => window.testMesh.count); + expect(renderCount).toBeGreaterThan(0); + }); +}); + +/** + * High-confidence deterministic frustum culling tests + * + * These tests verify exact instance IDs are culled/rendered + * by placing instances at known positions and checking internal state. + */ +test.describe('Deterministic Frustum Culling', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/fixtures/test-scene.html'); + await page.waitForFunction(() => window.sceneReady === true); + }); + + test('should cull specific instances outside frustum and keep specific instances inside', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(geometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + + // Camera at (0, 0, 50) looking at origin + window.camera.position.set(0, 0, 50); + window.camera.lookAt(0, 0, 0); + window.camera.near = 1; + window.camera.far = 200; + window.camera.updateProjectionMatrix(); + window.camera.updateMatrixWorld(); + + // Create instances at known positions relative to camera + // Camera is at z=50 looking toward origin (negative Z direction) + mesh.addInstances(6, (obj, index) => { + switch (index) { + case 0: obj.position.set(0, 0, 0); break; // In front of camera - VISIBLE + case 1: obj.position.set(0, 0, 200); break; // Behind camera - CULLED + case 2: obj.position.set(5, 0, 10); break; // In front, slight offset - VISIBLE + case 3: obj.position.set(1000, 0, 0); break; // Far to the side - CULLED + case 4: obj.position.set(0, 0, 30); break; // In front, closer - VISIBLE + case 5: obj.position.set(0, 1000, 0); break; // Far above - CULLED + } + }); + + window.scene.add(mesh); + window.testMesh = mesh; + + mesh.performFrustumCulling(window.camera); + + // Get the rendered instance IDs + const renderedCount = mesh.count; + const renderedIds = Array.from(mesh.instanceIndex.array.slice(0, renderedCount)); + + return { count: renderedCount, renderedIds }; + }); + + // Verify exact count + expect(result.count).toBe(3); + + // Verify specific instances are rendered (0, 2, 4 are in view) + expect(result.renderedIds).toContain(0); + expect(result.renderedIds).toContain(2); + expect(result.renderedIds).toContain(4); + + // Verify specific instances are culled (1, 3, 5 are out of view) + expect(result.renderedIds).not.toContain(1); + expect(result.renderedIds).not.toContain(3); + expect(result.renderedIds).not.toContain(5); + }); + + test('should correctly handle instances at frustum near/far boundaries', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(geometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + + // Camera at origin looking at -Z with specific near/far + window.camera.position.set(0, 0, 0); + window.camera.lookAt(0, 0, -1); + window.camera.near = 10; + window.camera.far = 100; + window.camera.updateProjectionMatrix(); + window.camera.updateMatrixWorld(); + + // Create instances at boundary positions + mesh.addInstances(6, (obj, index) => { + switch (index) { + case 0: obj.position.set(0, 0, -5); break; // Before near plane (z=-5) - CULLED + case 1: obj.position.set(0, 0, -10); break; // At near plane - VISIBLE + case 2: obj.position.set(0, 0, -50); break; // Middle of frustum - VISIBLE + case 3: obj.position.set(0, 0, -100); break; // At far plane - VISIBLE + case 4: obj.position.set(0, 0, -150); break; // Beyond far plane - CULLED + case 5: obj.position.set(0, 0, -75); break; // Middle - VISIBLE + } + }); + + window.scene.add(mesh); + window.testMesh = mesh; + + mesh.performFrustumCulling(window.camera); + + const renderedCount = mesh.count; + const renderedIds = Array.from(mesh.instanceIndex.array.slice(0, renderedCount)); + + return { count: renderedCount, renderedIds }; + }); + + // Instances 1, 2, 3, 5 should be visible (within near-far range) + expect(result.renderedIds).toContain(1); + expect(result.renderedIds).toContain(2); + expect(result.renderedIds).toContain(3); + expect(result.renderedIds).toContain(5); + + // Instances 0 and 4 should be culled (outside near-far range) + expect(result.renderedIds).not.toContain(0); + expect(result.renderedIds).not.toContain(4); + + expect(result.count).toBe(4); + }); + + test('should correctly handle instances at frustum left/right/top/bottom boundaries', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(geometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + + // Camera with known FOV to calculate exact frustum boundaries + window.camera.position.set(0, 0, 0); + window.camera.lookAt(0, 0, -1); + window.camera.fov = 90; // 90 degree FOV makes calculations easier + window.camera.aspect = 1; // Square aspect ratio + window.camera.near = 1; + window.camera.far = 100; + window.camera.updateProjectionMatrix(); + window.camera.updateMatrixWorld(); + + // At 90 degree FOV and aspect 1, at distance Z, frustum width = 2*Z + // At z=-20, frustum extends from x=-20 to x=+20 + mesh.addInstances(8, (obj, index) => { + switch (index) { + case 0: obj.position.set(0, 0, -20); break; // Center - VISIBLE + case 1: obj.position.set(15, 0, -20); break; // Right, in view - VISIBLE + case 2: obj.position.set(-15, 0, -20); break; // Left, in view - VISIBLE + case 3: obj.position.set(0, 15, -20); break; // Top, in view - VISIBLE + case 4: obj.position.set(0, -15, -20); break; // Bottom, in view - VISIBLE + case 5: obj.position.set(30, 0, -20); break; // Far right - CULLED + case 6: obj.position.set(-30, 0, -20); break; // Far left - CULLED + case 7: obj.position.set(0, 30, -20); break; // Far top - CULLED + } + }); + + window.scene.add(mesh); + window.testMesh = mesh; + + mesh.performFrustumCulling(window.camera); + + const renderedCount = mesh.count; + const renderedIds = Array.from(mesh.instanceIndex.array.slice(0, renderedCount)); + + return { count: renderedCount, renderedIds }; + }); + + // Instances 0-4 should be visible (inside frustum) + expect(result.renderedIds).toContain(0); + expect(result.renderedIds).toContain(1); + expect(result.renderedIds).toContain(2); + expect(result.renderedIds).toContain(3); + expect(result.renderedIds).toContain(4); + + // Instances 5-7 should be culled (outside frustum) + expect(result.renderedIds).not.toContain(5); + expect(result.renderedIds).not.toContain(6); + expect(result.renderedIds).not.toContain(7); + + expect(result.count).toBe(5); + }); + + test('should produce same culling results with and without BVH', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(geometry, material, { + capacity: 200, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + + // Camera setup + window.camera.position.set(0, 0, 50); + window.camera.lookAt(0, 0, 0); + window.camera.updateMatrixWorld(); + + // Create instances at various positions + mesh.addInstances(50, (obj, index) => { + // Create a spread of instances, some visible, some not + const angle = (index / 50) * Math.PI * 2; + const radius = index % 2 === 0 ? 20 : 200; // Alternating near/far + obj.position.set( + Math.cos(angle) * radius, + Math.sin(angle) * radius * 0.5, + Math.sin(angle) * radius + ); + }); + + window.scene.add(mesh); + window.testMesh = mesh; + + // Culling WITHOUT BVH + mesh.performFrustumCulling(window.camera); + const withoutBVHCount = mesh.count; + const withoutBVHIds = Array.from(mesh.instanceIndex.array.slice(0, withoutBVHCount)).sort((a, b) => a - b); + + // Create BVH + mesh.computeBVH(); + + // Culling WITH BVH + mesh.performFrustumCulling(window.camera); + const withBVHCount = mesh.count; + const withBVHIds = Array.from(mesh.instanceIndex.array.slice(0, withBVHCount)).sort((a, b) => a - b); + + return { + withoutBVH: { count: withoutBVHCount, ids: withoutBVHIds }, + withBVH: { count: withBVHCount, ids: withBVHIds }, + hasBVH: mesh.bvh !== null + }; + }); + + // Verify BVH was created + expect(result.hasBVH).toBe(true); + + // Verify same counts + expect(result.withBVH.count).toBe(result.withoutBVH.count); + + // Verify same instance IDs are rendered + expect(result.withBVH.ids).toEqual(result.withoutBVH.ids); + }); + + test('should track correct instance IDs when instances are hidden', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(geometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + + // Camera looking at origin + window.camera.position.set(0, 0, 50); + window.camera.lookAt(0, 0, 0); + window.camera.updateMatrixWorld(); + + // Create 10 instances all in view + mesh.addInstances(10, (obj, index) => { + obj.position.set((index - 5) * 2, 0, 0); // Spread along X axis + }); + + // Hide specific instances + mesh.setVisibilityAt(2, false); + mesh.setVisibilityAt(5, false); + mesh.setVisibilityAt(8, false); + + window.scene.add(mesh); + window.testMesh = mesh; + + mesh.performFrustumCulling(window.camera); + + const renderedCount = mesh.count; + const renderedIds = Array.from(mesh.instanceIndex.array.slice(0, renderedCount)); + + return { count: renderedCount, renderedIds }; + }); + + // 10 instances - 3 hidden = 7 visible + expect(result.count).toBe(7); + + // Hidden instances should not be in render list + expect(result.renderedIds).not.toContain(2); + expect(result.renderedIds).not.toContain(5); + expect(result.renderedIds).not.toContain(8); + + // Visible instances should be in render list + expect(result.renderedIds).toContain(0); + expect(result.renderedIds).toContain(1); + expect(result.renderedIds).toContain(3); + expect(result.renderedIds).toContain(4); + expect(result.renderedIds).toContain(6); + expect(result.renderedIds).toContain(7); + expect(result.renderedIds).toContain(9); + }); +}); + diff --git a/tests/e2e/lod-switching.spec.ts b/tests/e2e/lod-switching.spec.ts new file mode 100644 index 0000000..9418f2e --- /dev/null +++ b/tests/e2e/lod-switching.spec.ts @@ -0,0 +1,440 @@ +/** + * E2E tests for LOD (Level of Detail) switching + * + * Tests actual distance-based LOD switching with real rendering. + */ + +import { test, expect } from '@playwright/test'; + +test.describe('LOD Switching E2E', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/fixtures/test-scene.html'); + await page.waitForFunction(() => window.sceneReady === true); + }); + + test('should create LOD levels', async ({ page }) => { + const hasLOD = await page.evaluate(() => { + const { BoxGeometry, SphereGeometry, MeshBasicMaterial } = window.THREE; + + const highGeometry = new SphereGeometry(0.5, 32, 32); + const midGeometry = new SphereGeometry(0.5, 16, 16); + const lowGeometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(highGeometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + mesh.addLOD(midGeometry, material, 50); + mesh.addLOD(lowGeometry, material, 100); + + mesh.addInstances(50, (obj, index) => { + obj.position.set( + (Math.random() - 0.5) * 100, + (Math.random() - 0.5) * 100, + (Math.random() - 0.5) * 100 + ); + }); + + window.scene.add(mesh); + window.testMesh = mesh; + + return mesh.LODinfo !== null && mesh.LODinfo.render.levels.length === 3; + }); + + expect(hasLOD).toBe(true); + }); + + test('should render different LOD levels based on distance', async ({ page }) => { + await page.evaluate(() => { + const { BoxGeometry, SphereGeometry, MeshBasicMaterial } = window.THREE; + + const highGeometry = new SphereGeometry(0.5, 32, 32); + const lowGeometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(highGeometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + mesh.addLOD(lowGeometry, material, 50); + + // Create instances at known distances + mesh.addInstances(10, (obj, index) => { + // Near instances (within 50 units) + if (index < 5) { + obj.position.set(0, 0, 20); + } else { + // Far instances (beyond 50 units) + obj.position.set(0, 0, -100); + } + }); + + window.scene.add(mesh); + window.testMesh = mesh; + + // Trigger culling + mesh.performFrustumCulling(window.camera); + }); + + await page.waitForTimeout(100); + + // Check that LOD objects have different counts + const lodInfo = await page.evaluate(() => { + const mesh = window.testMesh; + return { + level0Count: mesh.LODinfo.objects[0].count, + level1Count: mesh.LODinfo.objects[1].count, + totalLevels: mesh.LODinfo.render.levels.length + }; + }); + + expect(lodInfo.totalLevels).toBe(2); + // Near and far instances should be distributed across LOD levels + }); + + test('should update LOD when camera moves', async ({ page }) => { + await page.evaluate(() => { + const { BoxGeometry, SphereGeometry, MeshBasicMaterial } = window.THREE; + + const highGeometry = new SphereGeometry(0.5, 32, 32); + const lowGeometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(highGeometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + mesh.addLOD(lowGeometry, material, 30); + + // All instances at origin + mesh.addInstances(20, (obj, index) => { + obj.position.set(0, 0, 0); + }); + + window.scene.add(mesh); + window.testMesh = mesh; + }); + + // Camera close - should use high LOD + await page.evaluate(() => { + window.camera.position.set(0, 0, 20); + window.camera.lookAt(0, 0, 0); + window.camera.updateMatrixWorld(); + window.testMesh.performFrustumCulling(window.camera); + }); + + const closeHighCount = await page.evaluate(() => + window.testMesh.LODinfo.objects[0].count + ); + + // Camera far - should use low LOD + await page.evaluate(() => { + window.camera.position.set(0, 0, 100); + window.camera.lookAt(0, 0, 0); + window.camera.updateMatrixWorld(); + window.testMesh.performFrustumCulling(window.camera); + }); + + const farHighCount = await page.evaluate(() => + window.testMesh.LODinfo.objects[0].count + ); + const farLowCount = await page.evaluate(() => + window.testMesh.LODinfo.objects[1].count + ); + + // When camera is close, high LOD should have more instances + // When camera is far, low LOD should have more instances + expect(closeHighCount).toBeGreaterThan(0); + expect(farLowCount).toBeGreaterThan(farHighCount); + }); + + test('should support shadow LOD', async ({ page }) => { + const hasShadowLOD = await page.evaluate(() => { + const { BoxGeometry, SphereGeometry, MeshBasicMaterial } = window.THREE; + + const highGeometry = new SphereGeometry(0.5, 32, 32); + const shadowGeometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(highGeometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + mesh.addShadowLOD(shadowGeometry, 0); + + window.testMesh = mesh; + + return mesh.LODinfo.shadowRender !== null && + mesh.LODinfo.shadowRender.levels.length > 0 && + mesh.castShadow === true; + }); + + expect(hasShadowLOD).toBe(true); + }); +}); + +/** + * High-confidence deterministic LOD tests + * + * These tests verify exact instance ID assignments to LOD levels + * by placing instances at known distances and checking internal state. + */ +test.describe('Deterministic LOD Assignment', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/fixtures/test-scene.html'); + await page.waitForFunction(() => window.sceneReady === true); + }); + + test('should assign instances to correct LOD based on exact distance', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, SphereGeometry, MeshBasicMaterial } = window.THREE; + + // Setup: LOD 0 for 0-50 units, LOD 1 for 50+ units + const highGeometry = new SphereGeometry(0.5, 32, 32); + const lowGeometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(highGeometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + mesh.addLOD(lowGeometry, material, 50); // LOD 1 at 50 units distance + + // Position camera at origin looking at -Z with wide FOV + window.camera.position.set(0, 0, 0); + window.camera.lookAt(0, 0, -1); + window.camera.fov = 90; + window.camera.near = 1; + window.camera.far = 500; + window.camera.updateProjectionMatrix(); + window.camera.updateMatrixWorld(); + + // Create 4 instances at known distances from camera (at origin) + // All instances placed IN FRONT of camera (in -Z direction) to stay in frustum + // Instances 0,1: at ~30 units distance (should be LOD 0) + // Instances 2,3: at ~80 units distance (should be LOD 1) + mesh.addInstances(4, (obj, index) => { + if (index === 0) obj.position.set(0, 0, -30); // 30 units in front - LOD 0 + if (index === 1) obj.position.set(5, 0, -29.6); // ~30 units in front - LOD 0 + if (index === 2) obj.position.set(0, 0, -80); // 80 units in front - LOD 1 + if (index === 3) obj.position.set(10, 0, -79.4); // ~80 units in front - LOD 1 + }); + + window.scene.add(mesh); + window.testMesh = mesh; + + // Perform frustum culling (which also does LOD assignment) + mesh.performFrustumCulling(window.camera); + + // Extract LOD assignment data + const lod0Count = mesh.LODinfo.objects[0].count; + const lod1Count = mesh.LODinfo.objects[1].count; + const lod0Ids = Array.from(mesh.LODinfo.objects[0].instanceIndex.array.slice(0, lod0Count)); + const lod1Ids = Array.from(mesh.LODinfo.objects[1].instanceIndex.array.slice(0, lod1Count)); + + return { lod0Count, lod1Count, lod0Ids, lod1Ids }; + }); + + // Verify exact counts + expect(result.lod0Count).toBe(2); + expect(result.lod1Count).toBe(2); + + // Verify exact instance IDs in each LOD level + expect(result.lod0Ids.sort((a, b) => a - b)).toEqual([0, 1]); + expect(result.lod1Ids.sort((a, b) => a - b)).toEqual([2, 3]); + }); + + test('should reassign LOD levels when camera distance changes', async ({ page }) => { + // Setup: Create instances all at origin + await page.evaluate(() => { + const { BoxGeometry, SphereGeometry, MeshBasicMaterial } = window.THREE; + + const highGeometry = new SphereGeometry(0.5, 32, 32); + const lowGeometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(highGeometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + mesh.addLOD(lowGeometry, material, 50); // LOD 1 at 50 units + + // All 5 instances at origin + mesh.addInstances(5, (obj, index) => { + obj.position.set(0, 0, 0); + }); + + window.scene.add(mesh); + window.testMesh = mesh; + }); + + // Phase 1: Camera close (20 units) - all should be LOD 0 + const closeResult = await page.evaluate(() => { + window.camera.position.set(0, 0, 20); + window.camera.lookAt(0, 0, 0); + window.camera.updateMatrixWorld(); + window.testMesh.performFrustumCulling(window.camera); + + return { + lod0Count: window.testMesh.LODinfo.objects[0].count, + lod1Count: window.testMesh.LODinfo.objects[1].count + }; + }); + + expect(closeResult.lod0Count).toBe(5); + expect(closeResult.lod1Count).toBe(0); + + // Phase 2: Camera far (100 units) - all should be LOD 1 + const farResult = await page.evaluate(() => { + window.camera.position.set(0, 0, 100); + window.camera.lookAt(0, 0, 0); + window.camera.updateMatrixWorld(); + window.testMesh.performFrustumCulling(window.camera); + + return { + lod0Count: window.testMesh.LODinfo.objects[0].count, + lod1Count: window.testMesh.LODinfo.objects[1].count + }; + }); + + expect(farResult.lod0Count).toBe(0); + expect(farResult.lod1Count).toBe(5); + }); + + test('should handle instance at exact LOD boundary distance', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, SphereGeometry, MeshBasicMaterial } = window.THREE; + + const highGeometry = new SphereGeometry(0.5, 32, 32); + const lowGeometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(highGeometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + mesh.addLOD(lowGeometry, material, 50); // Boundary at 50 units + + // Camera at origin + window.camera.position.set(0, 0, 0); + window.camera.lookAt(0, 0, -1); + window.camera.updateMatrixWorld(); + + // Create instances at and around boundary + mesh.addInstances(4, (obj, index) => { + if (index === 0) obj.position.set(0, 0, -49.9); // Just inside LOD 0 + if (index === 1) obj.position.set(0, 0, -50.0); // Exactly at boundary + if (index === 2) obj.position.set(0, 0, -50.1); // Just outside to LOD 1 + if (index === 3) obj.position.set(0, 0, -60); // Clearly LOD 1 + }); + + window.scene.add(mesh); + window.testMesh = mesh; + + mesh.performFrustumCulling(window.camera); + + const lod0Count = mesh.LODinfo.objects[0].count; + const lod1Count = mesh.LODinfo.objects[1].count; + const lod0Ids = Array.from(mesh.LODinfo.objects[0].instanceIndex.array.slice(0, lod0Count)); + const lod1Ids = Array.from(mesh.LODinfo.objects[1].instanceIndex.array.slice(0, lod1Count)); + + return { lod0Count, lod1Count, lod0Ids, lod1Ids }; + }); + + // Instance 0 (49.9) should be in LOD 0 + // Instance 1 (50.0 - exact boundary) - verify it's assigned consistently + // Instances 2,3 should be in LOD 1 + + // At boundary, the comparison is typically < threshold, so exactly 50 goes to LOD 1 + expect(result.lod0Ids).toContain(0); + expect(result.lod1Ids).toContain(2); + expect(result.lod1Ids).toContain(3); + + // Boundary instance (1) should be consistently assigned to one level + // It will go to LOD 1 since comparison is typically < (not <=) + expect(result.lod0Count + result.lod1Count).toBe(4); + }); + + test('should correctly assign multiple LOD levels (3+ levels)', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, SphereGeometry, MeshBasicMaterial } = window.THREE; + + const highGeometry = new SphereGeometry(0.5, 32, 32); + const midGeometry = new SphereGeometry(0.5, 16, 16); + const lowGeometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(highGeometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + mesh.addLOD(midGeometry, material, 30); // LOD 1 at 30 units + mesh.addLOD(lowGeometry, material, 60); // LOD 2 at 60 units + + // Camera at origin looking at -Z with wide FOV + window.camera.position.set(0, 0, 0); + window.camera.lookAt(0, 0, -1); + window.camera.fov = 90; + window.camera.near = 1; + window.camera.far = 500; + window.camera.updateProjectionMatrix(); + window.camera.updateMatrixWorld(); + + // Create 6 instances: 2 for each LOD level + // All instances in front of camera to stay in frustum + mesh.addInstances(6, (obj, index) => { + if (index === 0) obj.position.set(0, 0, -15); // 15 units -> LOD 0 + if (index === 1) obj.position.set(3, 0, -14.7); // ~15 units -> LOD 0 + if (index === 2) obj.position.set(0, 0, -45); // 45 units -> LOD 1 + if (index === 3) obj.position.set(8, 0, -44.3); // ~45 units -> LOD 1 + if (index === 4) obj.position.set(0, 0, -80); // 80 units -> LOD 2 + if (index === 5) obj.position.set(15, 0, -78.6); // ~80 units -> LOD 2 + }); + + window.scene.add(mesh); + window.testMesh = mesh; + + mesh.performFrustumCulling(window.camera); + + const lod0Count = mesh.LODinfo.objects[0].count; + const lod1Count = mesh.LODinfo.objects[1].count; + const lod2Count = mesh.LODinfo.objects[2].count; + const lod0Ids = Array.from(mesh.LODinfo.objects[0].instanceIndex.array.slice(0, lod0Count)); + const lod1Ids = Array.from(mesh.LODinfo.objects[1].instanceIndex.array.slice(0, lod1Count)); + const lod2Ids = Array.from(mesh.LODinfo.objects[2].instanceIndex.array.slice(0, lod2Count)); + + return { + lod0Count, lod1Count, lod2Count, + lod0Ids, lod1Ids, lod2Ids, + totalLevels: mesh.LODinfo.render.levels.length + }; + }); + + expect(result.totalLevels).toBe(3); + expect(result.lod0Count).toBe(2); + expect(result.lod1Count).toBe(2); + expect(result.lod2Count).toBe(2); + + expect(result.lod0Ids.sort((a, b) => a - b)).toEqual([0, 1]); + expect(result.lod1Ids.sort((a, b) => a - b)).toEqual([2, 3]); + expect(result.lod2Ids.sort((a, b) => a - b)).toEqual([4, 5]); + }); +}); + diff --git a/tests/e2e/raycasting.spec.ts b/tests/e2e/raycasting.spec.ts new file mode 100644 index 0000000..404f115 --- /dev/null +++ b/tests/e2e/raycasting.spec.ts @@ -0,0 +1,138 @@ +/** + * E2E tests for Raycasting + * + * Tests actual raycasting with BVH optimization. + */ + +import { test, expect } from '@playwright/test'; + +test.describe('Raycasting E2E', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/fixtures/test-scene.html'); + await page.waitForFunction(() => window.sceneReady === true); + }); + + test('should raycast and find instances', async ({ page }) => { + const hitFound = await page.evaluate(() => { + const { Raycaster, Vector2 } = window.THREE; + + // Create mesh with instance at known position + const mesh = window.createTestMesh({ count: 1, spread: 0 }); + + // Position instance at origin + mesh.updateInstances((obj) => { + obj.position.set(0, 0, 0); + }); + + // Create raycaster pointing at origin + const raycaster = new Raycaster(); + raycaster.setFromCamera(new Vector2(0, 0), window.camera); + + const intersects = raycaster.intersectObject(mesh); + + return intersects.length > 0; + }); + + expect(hitFound).toBe(true); + }); + + test('should return instanceId in raycast result', async ({ page }) => { + const instanceId = await page.evaluate(() => { + const { Raycaster, Vector2 } = window.THREE; + + const mesh = window.createTestMesh({ count: 5, spread: 0 }); + + // Position all instances at origin + mesh.updateInstances((obj, index) => { + obj.position.set(0, 0, index * 2); + }); + + const raycaster = new Raycaster(); + raycaster.setFromCamera(new Vector2(0, 0), window.camera); + + const intersects = raycaster.intersectObject(mesh); + + if (intersects.length > 0) { + return intersects[0].instanceId; + } + return -1; + }); + + expect(instanceId).toBeGreaterThanOrEqual(0); + }); + + test('should use BVH for optimized raycasting', async ({ page }) => { + const result = await page.evaluate(() => { + const { Raycaster, Vector2 } = window.THREE; + + // Create mesh with many instances + const mesh = window.createTestMesh({ count: 1000, spread: 100 }); + mesh.computeBVH(); + + const raycaster = new Raycaster(); + raycaster.setFromCamera(new Vector2(0, 0), window.camera); + + const start = performance.now(); + const intersects = raycaster.intersectObject(mesh); + const duration = performance.now() - start; + + return { + hasBVH: mesh.bvh !== null, + hitCount: intersects.length, + duration + }; + }); + + expect(result.hasBVH).toBe(true); + // With BVH, raycasting should be fast even with many instances + expect(result.duration).toBeLessThan(100); // Should be much faster than 100ms + }); + + test('should only raycast visible instances', async ({ page }) => { + const result = await page.evaluate(() => { + const { Raycaster, Vector2 } = window.THREE; + + const mesh = window.createTestMesh({ count: 1, spread: 0 }); + + // Position instance at origin + mesh.updateInstances((obj) => { + obj.position.set(0, 0, 0); + }); + + // First raycast - should hit + const raycaster = new Raycaster(); + raycaster.setFromCamera(new Vector2(0, 0), window.camera); + const hitsBeforeHide = raycaster.intersectObject(mesh).length; + + // Hide the instance + mesh.setVisibilityAt(0, false); + + // Second raycast - should not hit hidden instance + const hitsAfterHide = raycaster.intersectObject(mesh).length; + + return { + hitsBeforeHide, + hitsAfterHide + }; + }); + + expect(result.hitsBeforeHide).toBeGreaterThan(0); + // Note: visibility filtering during raycast depends on implementation + }); + + test('should raycast with raycastOnlyFrustum option', async ({ page }) => { + await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 100, spread: 50 }); + mesh.raycastOnlyFrustum = true; + + // Perform frustum culling first + mesh.performFrustumCulling(window.camera); + + window.testMesh = mesh; + }); + + const setting = await page.evaluate(() => window.testMesh.raycastOnlyFrustum); + expect(setting).toBe(true); + }); +}); + diff --git a/tests/e2e/rendering.spec.ts b/tests/e2e/rendering.spec.ts new file mode 100644 index 0000000..27e36e2 --- /dev/null +++ b/tests/e2e/rendering.spec.ts @@ -0,0 +1,685 @@ +/** + * E2E tests for basic rendering functionality + * + * Tests that InstancedMesh2 renders correctly in a real WebGL context. + */ + +import { test, expect } from '@playwright/test'; + +test.describe('Rendering E2E', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/fixtures/test-scene.html'); + await page.waitForFunction(() => window.sceneReady === true); + }); + + test('should render instances on screen', async ({ page }) => { + await page.evaluate(() => { + window.createTestMesh({ count: 100, spread: 30 }); + }); + + // Wait for a few frames + await page.waitForTimeout(200); + + // Take screenshot to verify rendering + const screenshot = await page.screenshot(); + expect(screenshot).toBeTruthy(); + + // Check render count is greater than 0 + const renderCount = await page.evaluate(() => window.testMesh.count); + expect(renderCount).toBeGreaterThan(0); + }); + + test('should update display when instances added', async ({ page }) => { + // Start with empty mesh + const initialCount = await page.evaluate(() => { + window.createTestMesh({ count: 0 }); + return window.testMesh.instancesCount; + }); + + expect(initialCount).toBe(0); + + // Add instances + const newCount = await page.evaluate(() => { + window.testMesh.addInstances(50, (obj, index) => { + obj.position.set(index, 0, 0); + }); + return window.testMesh.instancesCount; + }); + + expect(newCount).toBe(50); + }); + + test('should handle instance removal', async ({ page }) => { + await page.evaluate(() => { + window.createTestMesh({ count: 10, spread: 10 }); + }); + + const initialCount = await page.evaluate(() => window.testMesh.instancesCount); + expect(initialCount).toBe(10); + + // Remove some instances + const afterRemoval = await page.evaluate(() => { + window.testMesh.removeInstances(0, 1, 2); + return window.testMesh.instancesCount; + }); + + expect(afterRemoval).toBe(7); + }); + + test('should handle clearInstances', async ({ page }) => { + await page.evaluate(() => { + window.createTestMesh({ count: 100, spread: 50 }); + }); + + const beforeClear = await page.evaluate(() => window.testMesh.instancesCount); + expect(beforeClear).toBe(100); + + const afterClear = await page.evaluate(() => { + window.testMesh.clearInstances(); + return window.testMesh.instancesCount; + }); + + expect(afterClear).toBe(0); + }); + + test('should apply instance colors', async ({ page }) => { + const result = await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 5, spread: 10 }); + + // Verify colorsTexture exists after mesh creation + const hasTexture = mesh.colorsTexture !== null; + const textureHasData = hasTexture && mesh.colorsTexture._data.length > 0; + + // Set different colors using Color objects (should not throw) + let setColorSuccess = false; + try { + const { Color } = window.THREE; + mesh.setColorAt(0, new Color(1, 0, 0)); // Red + mesh.setColorAt(1, new Color(0, 1, 0)); // Green + mesh.setColorAt(2, new Color(0, 0, 1)); // Blue + setColorSuccess = true; + } catch (e) { + setColorSuccess = false; + } + + // Verify getColorAt doesn't throw + let getColorSuccess = false; + try { + mesh.getColorAt(0); + mesh.getColorAt(1); + mesh.getColorAt(2); + getColorSuccess = true; + } catch (e) { + getColorSuccess = false; + } + + return { + hasTexture, + textureHasData, + setColorSuccess, + getColorSuccess + }; + }); + + expect(result.hasTexture).toBe(true); + expect(result.textureHasData).toBe(true); + expect(result.setColorSuccess).toBe(true); + expect(result.getColorSuccess).toBe(true); + }); + + test('should handle instance transformations', async ({ page }) => { + const transformApplied = await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 1, spread: 0, createEntities: true }); + + const instance = mesh.instances[0]; + instance.position.set(10, 20, 30); + instance.scale.set(2, 2, 2); + instance.updateMatrix(); + + const pos = mesh.getPositionAt(0); + + return { + x: Math.abs(pos.x - 10) < 0.001, + y: Math.abs(pos.y - 20) < 0.001, + z: Math.abs(pos.z - 30) < 0.001 + }; + }); + + expect(transformApplied.x).toBe(true); + expect(transformApplied.y).toBe(true); + expect(transformApplied.z).toBe(true); + }); + + test('should support capacity expansion', async ({ page }) => { + const expanded = await page.evaluate(() => { + // Start with small capacity + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(geometry, material, { + capacity: 10, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + window.scene.add(mesh); + window.testMesh = mesh; + + const initialCapacity = mesh.capacity; + + // Add more than capacity + mesh.addInstances(50, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + const finalCapacity = mesh.capacity; + + return { + initialCapacity, + finalCapacity, + instanceCount: mesh.instancesCount + }; + }); + + expect(expanded.initialCapacity).toBe(10); + expect(expanded.finalCapacity).toBeGreaterThan(10); + expect(expanded.instanceCount).toBe(50); + }); + + test('should render with WebGL context', async ({ page }) => { + const hasWebGL = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + if (!canvas) return false; + + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + return gl !== null; + }); + + expect(hasWebGL).toBe(true); + }); +}); + +/** + * Rendering Pipeline Verification Tests + * + * These tests verify the pipeline from internal state to GPU rendering, + * ensuring data is correctly uploaded and shaders are properly configured. + */ +test.describe('Rendering Pipeline Verification', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/fixtures/test-scene.html'); + await page.waitForFunction(() => window.sceneReady === true); + }); + + test('should have matricesTexture available for shader', async ({ page }) => { + const result = await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 10, spread: 10 }); + + // Force a render + window.renderer.render(window.scene, window.camera); + + // Verify the texture exists and has data for the shader + const hasMatricesTexture = mesh.matricesTexture !== null; + const matricesTextureHasData = hasMatricesTexture && mesh.matricesTexture._data.length > 0; + const hasColorsTexture = mesh.colorsTexture !== null; + const colorsTextureHasData = hasColorsTexture && mesh.colorsTexture._data.length > 0; + + // Verify the texture has the correct structure for shader binding + const textureWidth = mesh.matricesTexture?.image?.width ?? 0; + const textureHeight = mesh.matricesTexture?.image?.height ?? 0; + + return { + hasMatricesTexture, + matricesTextureHasData, + hasColorsTexture, + colorsTextureHasData, + textureWidth, + textureHeight, + hasValidDimensions: textureWidth > 0 && textureHeight > 0 + }; + }); + + expect(result.hasMatricesTexture).toBe(true); + expect(result.matricesTextureHasData).toBe(true); + expect(result.hasColorsTexture).toBe(true); + expect(result.hasValidDimensions).toBe(true); + }); + + test('should have instance index buffer after culling', async ({ page }) => { + const result = await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 5, spread: 10 }); + mesh.performFrustumCulling(window.camera); + window.renderer.render(window.scene, window.camera); + + // Verify buffer exists and was created + const hasInstanceIndex = mesh.instanceIndex !== null; + const hasBuffer = hasInstanceIndex && mesh.instanceIndex.buffer !== null; + const bufferCount = mesh.count; + const cpuIndices = hasInstanceIndex + ? Array.from(mesh.instanceIndex.array.slice(0, bufferCount)) + : []; + + return { hasInstanceIndex, hasBuffer, bufferCount, cpuIndices }; + }); + + expect(result.hasInstanceIndex).toBe(true); + expect(result.hasBuffer).toBe(true); + expect(result.bufferCount).toBeGreaterThan(0); + expect(result.cpuIndices.length).toBe(result.bufferCount); + }); + + test('should store correct transforms in matricesTexture', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial, Matrix4, Vector3 } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(geometry, material, { + capacity: 10, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + + // Add instances with known positions + mesh.addInstances(3, (obj, index) => { + if (index === 0) obj.position.set(10, 0, 0); + if (index === 1) obj.position.set(0, 20, 0); + if (index === 2) obj.position.set(0, 0, 30); + }); + + window.scene.add(mesh); + window.renderer.render(window.scene, window.camera); + + // Read back matrix data from texture + // Matrix is stored as 4 vec4s (16 floats) per instance + // Position is in the 4th column: indices 12, 13, 14 (x, y, z) + const data = mesh.matricesTexture._data; + const stride = 16; // 4x4 matrix = 16 floats + + const pos0 = [data[0 * stride + 12], data[0 * stride + 13], data[0 * stride + 14]]; + const pos1 = [data[1 * stride + 12], data[1 * stride + 13], data[1 * stride + 14]]; + const pos2 = [data[2 * stride + 12], data[2 * stride + 13], data[2 * stride + 14]]; + + return { + hasTexture: mesh.matricesTexture !== null, + pos0, + pos1, + pos2 + }; + }); + + expect(result.hasTexture).toBe(true); + expect(result.pos0).toEqual([10, 0, 0]); + expect(result.pos1).toEqual([0, 20, 0]); + expect(result.pos2).toEqual([0, 0, 30]); + }); + + test('should use correct geometry for each LOD level', async ({ page }) => { + const result = await page.evaluate(() => { + const { SphereGeometry, BoxGeometry, MeshBasicMaterial } = window.THREE; + + const highGeo = new SphereGeometry(1, 32, 32); + const lowGeo = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(highGeo, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + mesh.addLOD(lowGeo, material, 50); + + mesh.addInstances(5, (obj, index) => { + obj.position.set(0, 0, -20); // All instances in front + }); + + window.scene.add(mesh); + + // Get geometry vertex counts for verification + const lod0Geometry = mesh.LODinfo.objects[0].geometry; + const lod1Geometry = mesh.LODinfo.objects[1].geometry; + + const lod0VertexCount = lod0Geometry.attributes.position.count; + const lod1VertexCount = lod1Geometry.attributes.position.count; + + // Verify geometries are different objects + const geometriesAreDifferent = lod0Geometry !== lod1Geometry; + + return { + lod0VertexCount, + lod1VertexCount, + geometriesAreDifferent, + lod0HasMoreVerts: lod0VertexCount > lod1VertexCount + }; + }); + + expect(result.geometriesAreDifferent).toBe(true); + expect(result.lod0HasMoreVerts).toBe(true); + // Sphere (32x32) has ~500+ vertices, Box has 24 + expect(result.lod0VertexCount).toBeGreaterThan(100); + expect(result.lod1VertexCount).toBeLessThan(50); + }); + + test('should store correct colors in colorsTexture', async ({ page }) => { + const result = await page.evaluate(() => { + const { Color } = window.THREE; + const mesh = window.createTestMesh({ count: 3, spread: 10 }); + + // Set distinct colors + mesh.setColorAt(0, new Color(1, 0, 0)); // Red + mesh.setColorAt(1, new Color(0, 1, 0)); // Green + mesh.setColorAt(2, new Color(0, 0, 1)); // Blue + + window.renderer.render(window.scene, window.camera); + + // Read back color data from texture + // Colors are stored as 4 floats per instance (RGBA) + const data = mesh.colorsTexture._data; + const stride = 4; // RGBA + + return { + hasTexture: mesh.colorsTexture !== null, + // Check R channel for red, G for green, B for blue + color0R: data[0 * stride + 0], + color1G: data[1 * stride + 1], + color2B: data[2 * stride + 2] + }; + }); + + expect(result.hasTexture).toBe(true); + // Colors should be close to 1.0 (may have slight precision differences) + expect(result.color0R).toBeGreaterThan(0.9); + expect(result.color1G).toBeGreaterThan(0.9); + expect(result.color2B).toBeGreaterThan(0.9); + }); + + test('should update texture data when instances change', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial, Matrix4 } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(geometry, material, { + capacity: 10, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + + // Add instance with initial position + mesh.addInstances(1, (obj, index) => { + obj.position.set(5, 5, 5); + }); + + window.scene.add(mesh); + window.renderer.render(window.scene, window.camera); + + const data = mesh.matricesTexture._data; + const initialPos = [data[12], data[13], data[14]]; + + // Update position using setMatrixAt + const newMatrix = new Matrix4(); + newMatrix.setPosition(100, 200, 300); + mesh.setMatrixAt(0, newMatrix); + window.renderer.render(window.scene, window.camera); + + const updatedPos = [data[12], data[13], data[14]]; + + return { + initialPos, + updatedPos, + positionChanged: initialPos[0] !== updatedPos[0] + }; + }); + + expect(result.initialPos).toEqual([5, 5, 5]); + expect(result.updatedPos).toEqual([100, 200, 300]); + expect(result.positionChanged).toBe(true); + }); + + test('should have correct instance indices after frustum culling', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial } = window.THREE; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0x00ff00 }); + + const mesh = new window.InstancedMesh2(geometry, material, { + capacity: 100, + renderer: window.renderer + }); + + mesh.initMatricesTexture(); + + // Camera at z=50 looking at origin + window.camera.position.set(0, 0, 50); + window.camera.lookAt(0, 0, 0); + window.camera.updateMatrixWorld(); + + // Create instances: some in view, some out + mesh.addInstances(6, (obj, index) => { + switch (index) { + case 0: obj.position.set(0, 0, 0); break; // In view + case 1: obj.position.set(5, 0, 10); break; // In view + case 2: obj.position.set(0, 0, 200); break; // Behind camera - culled + case 3: obj.position.set(0, 0, 20); break; // In view + case 4: obj.position.set(500, 0, 0); break; // Far side - culled + case 5: obj.position.set(-5, 5, 0); break; // In view + } + }); + + window.scene.add(mesh); + + mesh.performFrustumCulling(window.camera); + window.renderer.render(window.scene, window.camera); + + // Get the instance indices that will be rendered + const renderedCount = mesh.count; + const renderedIndices = Array.from(mesh.instanceIndex.array.slice(0, renderedCount)); + + return { + totalInstances: mesh.instancesCount, + renderedCount, + renderedIndices: renderedIndices.sort((a, b) => a - b) + }; + }); + + expect(result.totalInstances).toBe(6); + expect(result.renderedCount).toBe(4); + // Instances 0, 1, 3, 5 should be visible + expect(result.renderedIndices).toEqual([0, 1, 3, 5]); + }); +}); + +/** + * Rendering Output Verification Tests + * + * These tests verify that rendering ACTUALLY produces visible output, + * not just that CPU-side state is correct. Uses draw call verification + * and GL error checks to ensure rendering works end-to-end. + * + * Note: Pixel readback tests are limited because the test fixture + * renderer doesn't use preserveDrawingBuffer. Instead, we verify + * rendering via draw call counts and triangles rendered. + */ +test.describe('Rendering Output Verification', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tests/fixtures/test-scene.html'); + await page.waitForFunction(() => window.sceneReady === true); + }); + + test('should execute draw calls when rendering', async ({ page }) => { + const result = await page.evaluate(() => { + const mesh = window.createTestMesh({ count: 10, spread: 10 }); + window.renderer.info.reset(); + + window.renderer.render(window.scene, window.camera); + + return { + drawCalls: window.renderer.info.render.calls, + triangles: window.renderer.info.render.triangles, + meshCount: mesh.count + }; + }); + + expect(result.drawCalls).toBeGreaterThan(0); + expect(result.triangles).toBeGreaterThan(0); + expect(result.meshCount).toBeGreaterThan(0); + }); + + test('should render more triangles with more instances', async ({ page }) => { + const result = await page.evaluate(() => { + // First render with few instances + const mesh1 = window.createTestMesh({ count: 5, spread: 10 }); + window.renderer.info.reset(); + window.renderer.render(window.scene, window.camera); + const triangles5 = window.renderer.info.render.triangles; + + // Clean up + window.scene.remove(mesh1); + + // Second render with more instances + const mesh2 = window.createTestMesh({ count: 50, spread: 10 }); + window.renderer.info.reset(); + window.renderer.render(window.scene, window.camera); + const triangles50 = window.renderer.info.render.triangles; + + return { triangles5, triangles50 }; + }); + + // More instances = more triangles rendered + expect(result.triangles50).toBeGreaterThan(result.triangles5); + }); + + test('should render zero triangles when all instances culled', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial, PerspectiveCamera } = window.THREE; + + // Clear scene + while (window.scene.children.length > 0) { + window.scene.remove(window.scene.children[0]); + } + + // Camera at origin looking at -Z + const camera = new PerspectiveCamera(75, 1, 0.1, 100); + camera.position.set(0, 0, 0); + camera.lookAt(0, 0, -1); + camera.updateMatrixWorld(); + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0xff0000 }); + + const mesh = new window.InstancedMesh2(geometry, material, { + capacity: 10, + renderer: window.renderer + }); + mesh.initMatricesTexture(); + + // All instances BEHIND camera (positive Z) + mesh.addInstances(10, (obj, i) => { + obj.position.set(0, 0, 100); // Behind camera + }); + + window.scene.add(mesh); + + mesh.performFrustumCulling(camera); + window.renderer.info.reset(); + window.renderer.render(window.scene, camera); + + return { + triangles: window.renderer.info.render.triangles, + meshCount: mesh.count, + instancesCount: mesh.instancesCount + }; + }); + + // All instances are behind camera, should be culled + expect(result.meshCount).toBe(0); + expect(result.triangles).toBe(0); + }); + + test('should render triangles when instances in view', async ({ page }) => { + const result = await page.evaluate(() => { + const { BoxGeometry, MeshBasicMaterial, PerspectiveCamera } = window.THREE; + + // Clear scene + while (window.scene.children.length > 0) { + window.scene.remove(window.scene.children[0]); + } + + // Camera at origin looking at -Z + const camera = new PerspectiveCamera(75, 1, 0.1, 100); + camera.position.set(0, 0, 10); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(); + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0xff0000 }); + + const mesh = new window.InstancedMesh2(geometry, material, { + capacity: 10, + renderer: window.renderer + }); + mesh.initMatricesTexture(); + + // All instances IN FRONT of camera + mesh.addInstances(10, (obj, i) => { + obj.position.set((i - 5) * 2, 0, 0); // Spread in view + }); + + window.scene.add(mesh); + + mesh.performFrustumCulling(camera); + window.renderer.info.reset(); + window.renderer.render(window.scene, camera); + + return { + triangles: window.renderer.info.render.triangles, + meshCount: mesh.count, + instancesCount: mesh.instancesCount + }; + }); + + // Instances in view should render triangles + expect(result.meshCount).toBeGreaterThan(0); + expect(result.triangles).toBeGreaterThan(0); + // Box has 12 triangles, so 10 boxes = 120 triangles + expect(result.triangles).toBeGreaterThanOrEqual(result.meshCount * 12); + }); + + test('should render successfully without exceptions', async ({ page }) => { + const result = await page.evaluate(() => { + try { + // Use the standard createTestMesh which we know works + const mesh = window.createTestMesh({ count: 10, spread: 10 }); + window.renderer.info.reset(); + window.renderer.render(window.scene, window.camera); + + return { + success: true, + meshCount: mesh.count, + triangles: window.renderer.info.render.triangles, + error: null + }; + } catch (e) { + return { + success: false, + meshCount: 0, + triangles: 0, + error: e.message + }; + } + }); + + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + expect(result.meshCount).toBeGreaterThan(0); + expect(result.triangles).toBeGreaterThan(0); + }); +}); + diff --git a/tests/features/bvh.test.ts b/tests/features/bvh.test.ts new file mode 100644 index 0000000..8984843 --- /dev/null +++ b/tests/features/bvh.test.ts @@ -0,0 +1,306 @@ +/** + * Tests for BVH Spatial Indexing feature + * + * Validates: + * - computeBVH() creates valid structure + * - insert/move/delete operations update BVH correctly + * - intersectBox() finds correct instances + * - BVH integration with frustum culling + * - disposeBVH() cleanup + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { Box3, Matrix4, Vector3 } from 'three'; +import { createTestInstancedMesh } from '../setup'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2.common'; + +describe('BVH Spatial Indexing', () => { + let mesh: InstancedMesh2; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + }); + + describe('computeBVH', () => { + it('should create BVH structure', () => { + mesh.addInstances(10, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + + mesh.computeBVH(); + + expect(mesh.bvh).not.toBeNull(); + }); + + it('should create BVH with margin option', () => { + mesh.addInstances(10, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + + mesh.computeBVH({ margin: 1 }); + + expect(mesh.bvh).not.toBeNull(); + expect(mesh.bvh['_margin']).toBe(1); + }); + + it('should create BVH with getBBoxFromBSphere option', () => { + mesh.addInstances(10, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + + mesh.computeBVH({ getBBoxFromBSphere: true }); + + expect(mesh.bvh).not.toBeNull(); + }); + + it('should rebuild BVH when called multiple times', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + mesh.computeBVH(); + const firstBVH = mesh.bvh; + + mesh.addInstances(5, (obj, index) => { + obj.position.set(index + 100, 0, 0); + }); + + mesh.computeBVH(); + + expect(mesh.bvh).toBe(firstBVH); // Same BVH instance, rebuilt + }); + + it('should populate nodesMap with instance nodes', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + + mesh.computeBVH(); + + expect(mesh.bvh.nodesMap.size).toBe(5); + }); + }); + + describe('disposeBVH', () => { + it('should set bvh to null', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + mesh.computeBVH(); + + mesh.disposeBVH(); + + expect(mesh.bvh).toBeNull(); + }); + }); + + describe('BVH insert', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + mesh.computeBVH(); + }); + + it('should insert new instances into BVH', () => { + const initialSize = mesh.bvh.nodesMap.size; + + mesh.addInstances(3, (obj, index) => { + obj.position.set(index * 10 + 100, 0, 0); + }); + + expect(mesh.bvh.nodesMap.size).toBe(initialSize + 3); + }); + + it('should have node for each active instance', () => { + mesh.addInstances(2, (obj, index) => { + obj.position.set(200, 0, 0); + }); + + // Check that all active instances have nodes + for (let i = 0; i < mesh.instancesCount; i++) { + if (mesh.getActiveAt(i)) { + expect(mesh.bvh.nodesMap.has(i)).toBe(true); + } + } + }); + }); + + describe('BVH move', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + mesh.computeBVH(); + }); + + it('should update BVH when instance matrix changes', () => { + const node = mesh.bvh.nodesMap.get(0); + const originalBox = [...node.box]; + + // Move instance to new position + mesh.setMatrixAt(0, new Matrix4().setPosition(100, 100, 100)); + + // BVH node box should be updated + expect(node.box).not.toEqual(originalBox); + }); + + it('should handle move for non-existent node gracefully', () => { + expect(() => mesh.bvh.move(999)).not.toThrow(); + }); + }); + + describe('BVH delete', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + mesh.computeBVH(); + }); + + it('should remove node from BVH when instance removed', () => { + expect(mesh.bvh.nodesMap.has(2)).toBe(true); + + mesh.removeInstances(2); + + expect(mesh.bvh.nodesMap.has(2)).toBe(false); + }); + + it('should decrease nodesMap size when instance removed', () => { + const initialSize = mesh.bvh.nodesMap.size; + + mesh.removeInstances(0, 1); + + expect(mesh.bvh.nodesMap.size).toBe(initialSize - 2); + }); + + it('should handle delete for non-existent node gracefully', () => { + expect(() => mesh.bvh.delete(999)).not.toThrow(); + }); + }); + + describe('BVH clear', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + mesh.computeBVH(); + }); + + it('should clear BVH when clearInstances called', () => { + mesh.clearInstances(); + + expect(mesh.bvh.nodesMap.size).toBe(0); + }); + }); + + describe('intersectBox', () => { + beforeEach(() => { + // Create instances spread out in space + mesh.addInstances(9, (obj, index) => { + const x = (index % 3) * 20; + const y = Math.floor(index / 3) * 20; + obj.position.set(x, y, 0); + }); + mesh.computeBVH(); + }); + + it('should find instances within box', () => { + const box = new Box3( + new Vector3(-5, -5, -5), + new Vector3(25, 5, 5) + ); + + const found: number[] = []; + mesh.bvh.intersectBox(box, (index) => { + found.push(index); + return false; // Continue searching + }); + + // Should find instances at x=0, x=20 (y=0 row) + expect(found.length).toBeGreaterThan(0); + }); + + it('should return true when intersection found and callback returns true', () => { + const box = new Box3( + new Vector3(-5, -5, -5), + new Vector3(5, 5, 5) + ); + + const result = mesh.bvh.intersectBox(box, () => true); + + expect(result).toBe(true); + }); + + it('should return false when no intersection found', () => { + const box = new Box3( + new Vector3(1000, 1000, 1000), + new Vector3(1100, 1100, 1100) + ); + + const found: number[] = []; + const result = mesh.bvh.intersectBox(box, (index) => { + found.push(index); + return false; + }); + + expect(found.length).toBe(0); + expect(result).toBe(false); + }); + + it('should stop early when callback returns true', () => { + const box = new Box3( + new Vector3(-100, -100, -100), + new Vector3(100, 100, 100) + ); + + let callCount = 0; + mesh.bvh.intersectBox(box, () => { + callCount++; + return true; // Stop after first hit + }); + + expect(callCount).toBe(1); + }); + }); + + describe('BVH with frustum culling', () => { + beforeEach(() => { + mesh.addInstances(10, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + mesh.computeBVH(); + }); + + it('should use BVH for culling when available', () => { + expect(mesh.bvh).not.toBeNull(); + // The BVHCulling method should be used when bvh exists + // This is more thoroughly tested in e2e tests + }); + + it('should have accurateCulling enabled by default', () => { + expect(mesh.bvh.accurateCulling).toBe(true); + }); + + it('should allow disabling accurateCulling', () => { + mesh.disposeBVH(); + mesh.computeBVH({ accurateCulling: false }); + + expect(mesh.bvh.accurateCulling).toBe(false); + }); + }); + + describe('geoBoundingBox', () => { + it('should store geometry bounding box', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + mesh.computeBVH(); + + expect(mesh.bvh.geoBoundingBox).not.toBeNull(); + expect(mesh.bvh.geoBoundingBox.min).toBeDefined(); + expect(mesh.bvh.geoBoundingBox.max).toBeDefined(); + }); + }); +}); + diff --git a/tests/features/dynamic-capacity.test.ts b/tests/features/dynamic-capacity.test.ts new file mode 100644 index 0000000..b86e8f1 --- /dev/null +++ b/tests/features/dynamic-capacity.test.ts @@ -0,0 +1,260 @@ +/** + * Tests for Dynamic Capacity feature + * + * Validates: + * - Adding instances up to and beyond capacity + * - Auto-expanding buffers when capacity exceeded + * - Removing instances by ID + * - Clearing all instances + * - Reusing freed instance slots + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestInstancedMesh, createTestInstancedMeshWithEntities } from '../setup'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2.common'; + +describe('Dynamic Capacity', () => { + let mesh: InstancedMesh2; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 10 }); + }); + + describe('addInstances', () => { + it('should add instances within capacity', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + expect(mesh.instancesCount).toBe(5); + expect(mesh.capacity).toBe(10); + }); + + it('should add instances up to exact capacity', () => { + mesh.addInstances(10, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + expect(mesh.instancesCount).toBe(10); + expect(mesh.capacity).toBe(10); + }); + + it('should auto-expand buffer when exceeding capacity', () => { + mesh.addInstances(15, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + expect(mesh.instancesCount).toBe(15); + expect(mesh.capacity).toBeGreaterThan(10); + }); + + it('should set identity matrix when no callback provided', () => { + mesh.addInstances(3); + + expect(mesh.instancesCount).toBe(3); + + // Check that matrices are identity + const matrix = mesh.getMatrixAt(0); + expect(matrix.elements[0]).toBe(1); + expect(matrix.elements[5]).toBe(1); + expect(matrix.elements[10]).toBe(1); + expect(matrix.elements[15]).toBe(1); + }); + + it('should call onCreation callback with correct index', () => { + const indices: number[] = []; + + mesh.addInstances(5, (obj, index) => { + indices.push(index); + }); + + expect(indices).toEqual([0, 1, 2, 3, 4]); + }); + + it('should allow setting position in callback', () => { + mesh.addInstances(3, (obj, index) => { + obj.position.set(index * 10, index * 20, index * 30); + }); + + const pos0 = mesh.getPositionAt(0); + expect(pos0.x).toBe(0); + expect(pos0.y).toBe(0); + expect(pos0.z).toBe(0); + + const pos2 = mesh.getPositionAt(2); + expect(pos2.x).toBe(20); + expect(pos2.y).toBe(40); + expect(pos2.z).toBe(60); + }); + }); + + describe('removeInstances', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + it('should remove a single instance by ID', () => { + mesh.removeInstances(2); + + expect(mesh.instancesCount).toBe(4); + expect(mesh.getActiveAt(2)).toBe(false); + }); + + it('should remove multiple instances', () => { + mesh.removeInstances(1, 3); + + expect(mesh.instancesCount).toBe(3); + expect(mesh.getActiveAt(1)).toBe(false); + expect(mesh.getActiveAt(3)).toBe(false); + }); + + it('should not throw when removing non-existent instance', () => { + expect(() => mesh.removeInstances(99)).not.toThrow(); + expect(mesh.instancesCount).toBe(5); + }); + + it('should not remove already removed instance', () => { + mesh.removeInstances(2); + mesh.removeInstances(2); + + expect(mesh.instancesCount).toBe(4); + }); + + it('should keep other instances active after removal', () => { + mesh.removeInstances(2); + + expect(mesh.getActiveAt(0)).toBe(true); + expect(mesh.getActiveAt(1)).toBe(true); + expect(mesh.getActiveAt(3)).toBe(true); + expect(mesh.getActiveAt(4)).toBe(true); + }); + }); + + describe('clearInstances', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + it('should remove all instances', () => { + mesh.clearInstances(); + + expect(mesh.instancesCount).toBe(0); + }); + + it('should allow adding new instances after clearing', () => { + mesh.clearInstances(); + mesh.addInstances(3, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + expect(mesh.instancesCount).toBe(3); + }); + }); + + describe('slot reuse', () => { + it('should reuse freed slots when adding new instances', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 100, 0, 0); + }); + + // Remove instance at index 2 + mesh.removeInstances(2); + expect(mesh.instancesCount).toBe(4); + + // Add a new instance - should reuse slot 2 + mesh.addInstances(1, (obj, index) => { + obj.position.set(999, 0, 0); + }); + + expect(mesh.instancesCount).toBe(5); + + // The new instance should be at the freed slot + const pos = mesh.getPositionAt(2); + expect(pos.x).toBe(999); + }); + + it('should reuse multiple freed slots', () => { + mesh.addInstances(5); + mesh.removeInstances(1, 3); + expect(mesh.instancesCount).toBe(3); + + mesh.addInstances(2); + expect(mesh.instancesCount).toBe(5); + }); + }); + + describe('resizeBuffers', () => { + it('should increase capacity', () => { + mesh.resizeBuffers(50); + + expect(mesh.capacity).toBe(50); + }); + + it('should preserve existing instances when expanding', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + + mesh.resizeBuffers(50); + + expect(mesh.instancesCount).toBe(5); + + const pos2 = mesh.getPositionAt(2); + expect(pos2.x).toBe(20); + }); + + it('should allow reducing capacity', () => { + mesh.addInstances(3); + mesh.resizeBuffers(5); + + expect(mesh.capacity).toBe(5); + expect(mesh.instancesCount).toBe(3); + }); + }); + + describe('with entities enabled', () => { + let meshWithEntities: InstancedMesh2; + + beforeEach(() => { + meshWithEntities = createTestInstancedMeshWithEntities(10); + }); + + it('should create entity objects when adding instances', () => { + meshWithEntities.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + + expect(meshWithEntities.instances).not.toBeNull(); + expect(meshWithEntities.instances.length).toBeGreaterThanOrEqual(5); + }); + + it('should allow accessing instances array', () => { + meshWithEntities.addInstances(3, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + + const instance = meshWithEntities.instances[1]; + expect(instance.position.x).toBe(10); + }); + + it('should update entity when using instances array', () => { + meshWithEntities.addInstances(3, (obj, index) => { + obj.position.set(0, 0, 0); + }); + + const instance = meshWithEntities.instances[0]; + instance.position.set(100, 200, 300); + instance.updateMatrix(); + + const pos = meshWithEntities.getPositionAt(0); + expect(pos.x).toBe(100); + expect(pos.y).toBe(200); + expect(pos.z).toBe(300); + }); + }); +}); + diff --git a/tests/features/frustum-culling.test.ts b/tests/features/frustum-culling.test.ts new file mode 100644 index 0000000..7f2dd67 --- /dev/null +++ b/tests/features/frustum-culling.test.ts @@ -0,0 +1,231 @@ +/** + * Tests for Per-instance Frustum Culling feature + * + * Validates: + * - perObjectFrustumCulled property toggle + * - Index array updates based on visibility + * - onFrustumEnter callback behavior + * - Integration with visibility state + * + * Note: Full frustum intersection tests are in e2e/frustum-culling.spec.ts + * since they require a real camera and WebGL context. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { PerspectiveCamera } from 'three'; +import { createTestInstancedMesh } from '../setup'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2.common'; + +describe('Frustum Culling', () => { + let mesh: InstancedMesh2; + let camera: PerspectiveCamera; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + camera = new PerspectiveCamera(75, 1, 0.1, 1000); + camera.position.set(0, 0, 10); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(); + }); + + describe('perObjectFrustumCulled property', () => { + it('should default to true', () => { + expect(mesh.perObjectFrustumCulled).toBe(true); + }); + + it('should be settable to false', () => { + mesh.perObjectFrustumCulled = false; + expect(mesh.perObjectFrustumCulled).toBe(false); + }); + + it('should mark index array for update when changed', () => { + mesh.addInstances(5); + mesh.perObjectFrustumCulled = false; + expect(mesh['_indexArrayNeedsUpdate']).toBe(true); + }); + }); + + describe('updateIndexArray', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + it('should include all visible and active instances', () => { + mesh['_indexArrayNeedsUpdate'] = true; + mesh.updateIndexArray(); + + expect(mesh.count).toBe(5); + }); + + it('should exclude hidden instances', () => { + mesh.setVisibilityAt(2, false); + mesh['_indexArrayNeedsUpdate'] = true; + mesh.updateIndexArray(); + + expect(mesh.count).toBe(4); + }); + + it('should exclude removed instances', () => { + mesh.removeInstances(1, 3); + mesh['_indexArrayNeedsUpdate'] = true; + mesh.updateIndexArray(); + + expect(mesh.count).toBe(3); + }); + + it('should not update if flag is false', () => { + mesh['_indexArrayNeedsUpdate'] = false; + const originalCount = mesh.count; + mesh.setVisibilityAt(0, false); + mesh['_indexArrayNeedsUpdate'] = false; // Reset flag + mesh.updateIndexArray(); + + // Count should not change since flag was false + expect(mesh.count).toBe(originalCount); + }); + }); + + describe('performFrustumCulling', () => { + it('should set count to 0 when no instances exist', () => { + mesh.performFrustumCulling(camera); + expect(mesh.count).toBe(0); + }); + + it('should process instances when they exist', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(0, 0, 0); // All at origin, in front of camera + }); + + mesh.perObjectFrustumCulled = false; + mesh.performFrustumCulling(camera); + + expect(mesh.count).toBe(5); + }); + + it('should respect visibility when culling is disabled', () => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(0, 0, 0); + }); + mesh.setVisibilityAt(2, false); + + mesh.perObjectFrustumCulled = false; + mesh.performFrustumCulling(camera); + + expect(mesh.count).toBe(4); + }); + }); + + describe('onFrustumEnter callback', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(0, 0, 0); + }); + }); + + it('should allow setting onFrustumEnter callback', () => { + const callback = vi.fn(() => true); + mesh.onFrustumEnter = callback; + + expect(mesh.onFrustumEnter).toBe(callback); + }); + + it('should be called during linear culling when set', () => { + const callback = vi.fn(() => true); + mesh.onFrustumEnter = callback; + + // Trigger linear culling (no BVH) + mesh.linearCulling(camera); + + // Callback should be invoked for each visible instance in frustum + expect(callback).toHaveBeenCalled(); + }); + + it('should filter instances when callback returns false', () => { + // Only allow even indices + mesh.onFrustumEnter = (index) => index % 2 === 0; + + mesh.linearCulling(camera); + + // Only indices 0, 2, 4 should pass (3 instances) + expect(mesh.count).toBe(3); + }); + + it('should receive correct parameters', () => { + const callback = vi.fn(() => true); + mesh.onFrustumEnter = callback; + + mesh.linearCulling(camera); + + // First call should have index and camera + expect(callback).toHaveBeenCalledWith( + expect.any(Number), + camera + ); + }); + }); + + describe('autoUpdate property', () => { + it('should default to true', () => { + expect(mesh.autoUpdate).toBe(true); + }); + + it('should be settable', () => { + mesh.autoUpdate = false; + expect(mesh.autoUpdate).toBe(false); + }); + }); + + describe('raycastOnlyFrustum property', () => { + it('should default to false', () => { + expect(mesh.raycastOnlyFrustum).toBe(false); + }); + + it('should be settable', () => { + mesh.raycastOnlyFrustum = true; + expect(mesh.raycastOnlyFrustum).toBe(true); + }); + }); + + describe('integration with visibility', () => { + beforeEach(() => { + mesh.addInstances(10, (obj, index) => { + obj.position.set(0, 0, 0); + }); + }); + + it('should skip culling for hidden instances', () => { + // Hide half the instances + for (let i = 0; i < 5; i++) { + mesh.setVisibilityAt(i, false); + } + + mesh.perObjectFrustumCulled = false; + mesh.performFrustumCulling(camera); + + expect(mesh.count).toBe(5); + }); + + it('should skip culling for removed instances', () => { + mesh.removeInstances(0, 1, 2); + + mesh.perObjectFrustumCulled = false; + mesh.performFrustumCulling(camera); + + expect(mesh.count).toBe(7); + }); + + it('should handle mixed visibility and removal', () => { + mesh.removeInstances(0, 1); + mesh.setVisibilityAt(5, false); + mesh.setVisibilityAt(6, false); + + mesh.perObjectFrustumCulled = false; + mesh.performFrustumCulling(camera); + + expect(mesh.count).toBe(6); + }); + }); +}); + diff --git a/tests/features/lod.test.ts b/tests/features/lod.test.ts new file mode 100644 index 0000000..e6c1f4d --- /dev/null +++ b/tests/features/lod.test.ts @@ -0,0 +1,285 @@ +/** + * Tests for Level of Detail (LOD) feature + * + * Validates: + * - addLOD() registers levels correctly + * - setFirstLODDistance() configuration + * - addShadowLOD() for shadow-specific LODs + * - getObjectLODIndexForDistance() returns correct level + * - LOD level sorting and distance thresholds + * + * Note: Actual distance-based rendering tests are in e2e/lod-switching.spec.ts + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { BoxGeometry, MeshBasicMaterial, SphereGeometry } from 'three'; +import { createTestInstancedMesh } from '../setup'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2.common'; + +describe('Level of Detail (LOD)', () => { + let mesh: InstancedMesh2; + let lowPolyGeometry: BoxGeometry; + let midPolyGeometry: SphereGeometry; + let material: MeshBasicMaterial; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + lowPolyGeometry = new BoxGeometry(1, 1, 1, 1, 1, 1); + midPolyGeometry = new SphereGeometry(0.5, 8, 8); + material = new MeshBasicMaterial({ color: 0x00ff00 }); + }); + + describe('setFirstLODDistance', () => { + it('should initialize LODinfo structure', () => { + mesh.setFirstLODDistance(0, 0); + + expect(mesh.LODinfo).not.toBeNull(); + expect(mesh.LODinfo.render).not.toBeNull(); + expect(mesh.LODinfo.render.levels).toHaveLength(1); + }); + + it('should set first LOD with default values', () => { + mesh.setFirstLODDistance(); + + const firstLevel = mesh.LODinfo.render.levels[0]; + expect(firstLevel.distance).toBe(0); + expect(firstLevel.hysteresis).toBe(0); + expect(firstLevel.object).toBe(mesh); + }); + + it('should set first LOD with custom distance', () => { + mesh.setFirstLODDistance(100, 0.1); + + const firstLevel = mesh.LODinfo.render.levels[0]; + expect(firstLevel.distance).toBe(100); + expect(firstLevel.hysteresis).toBe(0.1); + }); + + it('should be chainable', () => { + const result = mesh.setFirstLODDistance(); + expect(result).toBe(mesh); + }); + + it('should include mesh in objects list', () => { + mesh.setFirstLODDistance(); + + expect(mesh.LODinfo.objects).toContain(mesh); + }); + }); + + describe('addLOD', () => { + it('should add LOD level with specified distance', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + + expect(mesh.LODinfo.render.levels).toHaveLength(2); + }); + + it('should throw when adding LOD at distance 0 without setFirstLODDistance', () => { + expect(() => mesh.addLOD(lowPolyGeometry, material, 0)).toThrow(); + }); + + it('should store squared distance internally', () => { + mesh.addLOD(lowPolyGeometry, material, 10); + + // Distance is squared: 10^2 = 100 + const lodLevel = mesh.LODinfo.render.levels[1]; + expect(lodLevel.distance).toBe(100); + }); + + it('should add multiple LOD levels in correct order', () => { + mesh.addLOD(midPolyGeometry, material, 50); + mesh.addLOD(lowPolyGeometry, material, 100); + + const levels = mesh.LODinfo.render.levels; + expect(levels).toHaveLength(3); + + // Levels should be sorted by distance (ascending, squared) + expect(levels[0].distance).toBe(0); + expect(levels[1].distance).toBe(2500); // 50^2 + expect(levels[2].distance).toBe(10000); // 100^2 + }); + + it('should create new InstancedMesh2 for each LOD geometry', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + mesh.addLOD(midPolyGeometry, material, 100); + + expect(mesh.LODinfo.objects).toHaveLength(3); + }); + + it('should reuse existing InstancedMesh2 for same geometry', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + mesh.addLOD(lowPolyGeometry, material, 100); + + // Should only create one additional object for lowPolyGeometry + expect(mesh.LODinfo.objects).toHaveLength(2); + }); + + it('should set hysteresis value', () => { + mesh.addLOD(lowPolyGeometry, material, 50, 0.2); + + const lodLevel = mesh.LODinfo.render.levels[1]; + expect(lodLevel.hysteresis).toBe(0.2); + }); + + it('should be chainable', () => { + const result = mesh.addLOD(lowPolyGeometry, material, 50); + expect(result).toBe(mesh); + }); + + it('should add LOD objects as children', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + + expect(mesh.children.length).toBeGreaterThan(0); + }); + }); + + describe('addShadowLOD', () => { + it('should create shadow render list', () => { + mesh.addShadowLOD(lowPolyGeometry, 0); + + expect(mesh.LODinfo.shadowRender).not.toBeNull(); + expect(mesh.LODinfo.shadowRender.levels).toHaveLength(1); + }); + + it('should enable castShadow on mesh', () => { + mesh.addShadowLOD(lowPolyGeometry, 0); + + expect(mesh.castShadow).toBe(true); + }); + + it('should add multiple shadow LOD levels', () => { + mesh.addShadowLOD(midPolyGeometry, 0); + mesh.addShadowLOD(lowPolyGeometry, 100); + + expect(mesh.LODinfo.shadowRender.levels).toHaveLength(2); + }); + + it('should be chainable', () => { + const result = mesh.addShadowLOD(lowPolyGeometry, 50); + expect(result).toBe(mesh); + }); + }); + + describe('getObjectLODIndexForDistance', () => { + beforeEach(() => { + mesh.setFirstLODDistance(0); + mesh.addLOD(midPolyGeometry, material, 50); + mesh.addLOD(lowPolyGeometry, material, 100); + }); + + it('should return 0 for distance below first threshold', () => { + const levels = mesh.LODinfo.render.levels; + const index = mesh.getObjectLODIndexForDistance(levels, 100); // sqrt(100) = 10 + + expect(index).toBe(0); + }); + + it('should return correct index for mid-range distance', () => { + const levels = mesh.LODinfo.render.levels; + // 50^2 = 2500, need distance >= 2500 for index 1 + const index = mesh.getObjectLODIndexForDistance(levels, 3000); + + expect(index).toBe(1); + }); + + it('should return last index for distance beyond all thresholds', () => { + const levels = mesh.LODinfo.render.levels; + // 100^2 = 10000, need distance >= 10000 for index 2 + const index = mesh.getObjectLODIndexForDistance(levels, 15000); + + expect(index).toBe(2); + }); + + it('should handle exact threshold distance', () => { + const levels = mesh.LODinfo.render.levels; + // Exactly at threshold (2500 = 50^2) + const index = mesh.getObjectLODIndexForDistance(levels, 2500); + + expect(index).toBe(1); + }); + + it('should account for hysteresis in distance calculation', () => { + // Create new mesh with hysteresis + const meshWithHysteresis = createTestInstancedMesh({ capacity: 100 }); + meshWithHysteresis.setFirstLODDistance(0); + meshWithHysteresis.addLOD(lowPolyGeometry, material, 100, 0.1); // 10% hysteresis + + const levels = meshWithHysteresis.LODinfo.render.levels; + // Distance threshold is 100^2 = 10000 + // With 10% hysteresis: 10000 - (10000 * 0.1) = 9000 + + // At 9000, should still be level 1 (hysteresis reduces threshold) + const indexAtHysteresis = meshWithHysteresis.getObjectLODIndexForDistance(levels, 9000); + expect(indexAtHysteresis).toBe(1); + + // Below hysteresis threshold, should be level 0 + const indexBelowHysteresis = meshWithHysteresis.getObjectLODIndexForDistance(levels, 8000); + expect(indexBelowHysteresis).toBe(0); + }); + }); + + describe('LOD count tracking', () => { + beforeEach(() => { + mesh.setFirstLODDistance(0); + mesh.addLOD(lowPolyGeometry, material, 50); + }); + + it('should initialize count array', () => { + expect(mesh.LODinfo.render.count).toHaveLength(2); + }); + + it('should have count array match levels array length', () => { + mesh.addLOD(midPolyGeometry, material, 100); + + expect(mesh.LODinfo.render.count.length).toBe( + mesh.LODinfo.render.levels.length + ); + }); + }); + + describe('LOD texture sharing', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + mesh.addLOD(lowPolyGeometry, material, 50); + }); + + it('should share matricesTexture with child LOD objects', () => { + const lodObject = mesh.LODinfo.objects[1]; + + expect(lodObject.matricesTexture).toBe(mesh.matricesTexture); + }); + + it('should share colorsTexture with child LOD objects', () => { + // Initialize colors texture + mesh.setColorAt(0, 0xff0000); + + const lodObject = mesh.LODinfo.objects[1]; + expect(lodObject.colorsTexture).toBe(mesh.colorsTexture); + }); + }); + + describe('error handling', () => { + it('should throw when creating LOD on child LOD object', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + const lodChild = mesh.LODinfo.objects[1]; + + expect(() => lodChild.addLOD(midPolyGeometry, material, 100)).toThrow(); + }); + + it('should throw when setting first LOD distance on child', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + const lodChild = mesh.LODinfo.objects[1]; + + expect(() => lodChild.setFirstLODDistance(0)).toThrow(); + }); + + it('should throw when adding shadow LOD on child', () => { + mesh.addLOD(lowPolyGeometry, material, 50); + const lodChild = mesh.LODinfo.objects[1]; + + expect(() => lodChild.addShadowLOD(midPolyGeometry)).toThrow(); + }); + }); +}); diff --git a/tests/features/sorting.test.ts b/tests/features/sorting.test.ts new file mode 100644 index 0000000..f3a3c12 --- /dev/null +++ b/tests/features/sorting.test.ts @@ -0,0 +1,251 @@ +/** + * Tests for Sorting feature + * + * Validates: + * - sortObjects property toggle + * - sortOpaque comparator (front-to-back) + * - sortTransparent comparator (back-to-front) + * - createRadixSort optimization + * - customSort callback integration + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { MeshBasicMaterial } from 'three'; +import { createTestInstancedMesh } from '../setup'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2.common'; +import { createRadixSort, sortOpaque, sortTransparent } from '../../src/utils/SortingUtils'; +import { InstancedRenderItem } from '../../src/core/utils/InstancedRenderList'; + +describe('Sorting', () => { + let mesh: InstancedMesh2; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + }); + + describe('sortObjects property', () => { + it('should default to false', () => { + expect(mesh.sortObjects).toBe(false); + }); + + it('should be settable to true', () => { + mesh.sortObjects = true; + expect(mesh.sortObjects).toBe(true); + }); + + it('should mark index array for update when changed', () => { + mesh.addInstances(5); + mesh.sortObjects = true; + expect(mesh['_indexArrayNeedsUpdate']).toBe(true); + }); + }); + + describe('sortOpaque', () => { + it('should sort front-to-back (smaller depth first)', () => { + const items: InstancedRenderItem[] = [ + { depth: 100, depthSort: 0, index: 0 }, + { depth: 50, depthSort: 0, index: 1 }, + { depth: 200, depthSort: 0, index: 2 }, + { depth: 10, depthSort: 0, index: 3 }, + ]; + + items.sort(sortOpaque); + + expect(items.map(i => i.depth)).toEqual([10, 50, 100, 200]); + }); + + it('should return negative when a.depth < b.depth', () => { + const a: InstancedRenderItem = { depth: 10, depthSort: 0, index: 0 }; + const b: InstancedRenderItem = { depth: 20, depthSort: 0, index: 1 }; + + expect(sortOpaque(a, b)).toBeLessThan(0); + }); + + it('should return positive when a.depth > b.depth', () => { + const a: InstancedRenderItem = { depth: 30, depthSort: 0, index: 0 }; + const b: InstancedRenderItem = { depth: 20, depthSort: 0, index: 1 }; + + expect(sortOpaque(a, b)).toBeGreaterThan(0); + }); + + it('should return 0 when depths are equal', () => { + const a: InstancedRenderItem = { depth: 50, depthSort: 0, index: 0 }; + const b: InstancedRenderItem = { depth: 50, depthSort: 0, index: 1 }; + + expect(sortOpaque(a, b)).toBe(0); + }); + }); + + describe('sortTransparent', () => { + it('should sort back-to-front (larger depth first)', () => { + const items: InstancedRenderItem[] = [ + { depth: 100, depthSort: 0, index: 0 }, + { depth: 50, depthSort: 0, index: 1 }, + { depth: 200, depthSort: 0, index: 2 }, + { depth: 10, depthSort: 0, index: 3 }, + ]; + + items.sort(sortTransparent); + + expect(items.map(i => i.depth)).toEqual([200, 100, 50, 10]); + }); + + it('should return positive when a.depth < b.depth', () => { + const a: InstancedRenderItem = { depth: 10, depthSort: 0, index: 0 }; + const b: InstancedRenderItem = { depth: 20, depthSort: 0, index: 1 }; + + expect(sortTransparent(a, b)).toBeGreaterThan(0); + }); + + it('should return negative when a.depth > b.depth', () => { + const a: InstancedRenderItem = { depth: 30, depthSort: 0, index: 0 }; + const b: InstancedRenderItem = { depth: 20, depthSort: 0, index: 1 }; + + expect(sortTransparent(a, b)).toBeLessThan(0); + }); + }); + + describe('createRadixSort', () => { + it('should create a radix sort function', () => { + const sortFn = createRadixSort(mesh); + + expect(typeof sortFn).toBe('function'); + }); + + it('should sort items by depthSort value', () => { + const sortFn = createRadixSort(mesh); + + const items: InstancedRenderItem[] = [ + { depth: 100, depthSort: 0, index: 0 }, + { depth: 50, depthSort: 0, index: 1 }, + { depth: 200, depthSort: 0, index: 2 }, + ]; + + sortFn(items); + + // After sorting, items should be ordered by depth (which determines depthSort) + const depths = items.map(i => i.depth); + expect(depths[0]).toBeLessThanOrEqual(depths[1]); + expect(depths[1]).toBeLessThanOrEqual(depths[2]); + }); + + it('should handle empty array', () => { + const sortFn = createRadixSort(mesh); + const items: InstancedRenderItem[] = []; + + expect(() => sortFn(items)).not.toThrow(); + }); + + it('should handle single item array', () => { + const sortFn = createRadixSort(mesh); + const items: InstancedRenderItem[] = [ + { depth: 100, depthSort: 0, index: 0 }, + ]; + + expect(() => sortFn(items)).not.toThrow(); + expect(items).toHaveLength(1); + }); + + it('should reverse order for transparent materials', () => { + const transparentMaterial = new MeshBasicMaterial({ + transparent: true, + opacity: 0.5 + }); + mesh.material = transparentMaterial; + + const sortFn = createRadixSort(mesh); + + const items: InstancedRenderItem[] = [ + { depth: 50, depthSort: 0, index: 0 }, + { depth: 100, depthSort: 0, index: 1 }, + { depth: 25, depthSort: 0, index: 2 }, + ]; + + sortFn(items); + + // For transparent, should be back-to-front (largest depth first) + const depths = items.map(i => i.depth); + expect(depths[0]).toBeGreaterThanOrEqual(depths[1]); + expect(depths[1]).toBeGreaterThanOrEqual(depths[2]); + }); + }); + + describe('customSort property', () => { + it('should default to null', () => { + expect(mesh.customSort).toBeNull(); + }); + + it('should accept custom sort function', () => { + const customFn = vi.fn(); + mesh.customSort = customFn; + + expect(mesh.customSort).toBe(customFn); + }); + + it('should accept radix sort function', () => { + const radixSort = createRadixSort(mesh); + mesh.customSort = radixSort; + + expect(mesh.customSort).toBe(radixSort); + }); + }); + + describe('sorting with visibility', () => { + beforeEach(() => { + mesh.addInstances(5, (obj, index) => { + obj.position.set(index * 10, 0, 0); + }); + }); + + it('should only sort visible instances', () => { + mesh.setVisibilityAt(2, false); + mesh.sortObjects = true; + + // The hidden instance should not be included in sorting + // This is tested more thoroughly in e2e tests + expect(mesh.getVisibilityAt(2)).toBe(false); + }); + }); + + describe('depthSort calculation', () => { + it('should compute depthSort based on depth range', () => { + const sortFn = createRadixSort(mesh); + + const items: InstancedRenderItem[] = [ + { depth: 100, depthSort: 0, index: 0 }, + { depth: 200, depthSort: 0, index: 1 }, + { depth: 300, depthSort: 0, index: 2 }, + ]; + + sortFn(items); + + // After sorting, depthSort should be computed (non-zero for normalized values) + // The actual values depend on the depth range normalization + expect(items[0].depthSort).toBeDefined(); + }); + }); + + describe('integration example', () => { + it('should demonstrate typical sorting setup', () => { + // Create mesh with transparent material + const transparentMaterial = new MeshBasicMaterial({ + transparent: true, + opacity: 0.5, + }); + mesh.material = transparentMaterial; + + // Add instances at various depths + mesh.addInstances(10, (obj, index) => { + obj.position.set(0, 0, index * 10); // Different z positions + }); + + // Enable sorting with radix sort optimization + mesh.sortObjects = true; + mesh.customSort = createRadixSort(mesh); + + expect(mesh.sortObjects).toBe(true); + expect(mesh.customSort).not.toBeNull(); + }); + }); +}); + diff --git a/tests/features/visibility.test.ts b/tests/features/visibility.test.ts new file mode 100644 index 0000000..c89fd1f --- /dev/null +++ b/tests/features/visibility.test.ts @@ -0,0 +1,349 @@ +/** + * Tests for Per-instance Visibility and Opacity features + * + * Validates: + * - setVisibilityAt/getVisibilityAt methods + * - instances[i].visible property (with entities) + * - setOpacityAt/getOpacityAt methods + * - instances[i].opacity property (with entities) + * - setActiveAt/getActiveAt methods + * - Hidden instances excluded from render count + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { Color } from 'three'; +import { createTestInstancedMesh, createTestInstancedMeshWithEntities } from '../setup'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2.common'; + +describe('Per-instance Visibility', () => { + let mesh: InstancedMesh2; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + mesh.addInstances(10, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + describe('setVisibilityAt / getVisibilityAt', () => { + it('should default to true for new instances', () => { + expect(mesh.getVisibilityAt(0)).toBe(true); + expect(mesh.getVisibilityAt(5)).toBe(true); + }); + + it('should set visibility to false', () => { + mesh.setVisibilityAt(3, false); + + expect(mesh.getVisibilityAt(3)).toBe(false); + }); + + it('should set visibility back to true', () => { + mesh.setVisibilityAt(3, false); + mesh.setVisibilityAt(3, true); + + expect(mesh.getVisibilityAt(3)).toBe(true); + }); + + it('should not affect other instances', () => { + mesh.setVisibilityAt(5, false); + + expect(mesh.getVisibilityAt(4)).toBe(true); + expect(mesh.getVisibilityAt(6)).toBe(true); + }); + + it('should mark index array for update', () => { + mesh['_indexArrayNeedsUpdate'] = false; + mesh.setVisibilityAt(0, false); + + expect(mesh['_indexArrayNeedsUpdate']).toBe(true); + }); + }); + + describe('setActiveAt / getActiveAt', () => { + it('should default to true for new instances', () => { + expect(mesh.getActiveAt(0)).toBe(true); + expect(mesh.getActiveAt(5)).toBe(true); + }); + + it('should set active to false', () => { + mesh.setActiveAt(3, false); + + expect(mesh.getActiveAt(3)).toBe(false); + }); + + it('should mark index array for update', () => { + mesh['_indexArrayNeedsUpdate'] = false; + mesh.setActiveAt(0, false); + + expect(mesh['_indexArrayNeedsUpdate']).toBe(true); + }); + }); + + describe('getActiveAndVisibilityAt', () => { + it('should return true when both active and visible', () => { + expect(mesh.getActiveAndVisibilityAt(0)).toBe(true); + }); + + it('should return false when not visible', () => { + mesh.setVisibilityAt(0, false); + + expect(mesh.getActiveAndVisibilityAt(0)).toBe(false); + }); + + it('should return false when not active', () => { + mesh.setActiveAt(0, false); + + expect(mesh.getActiveAndVisibilityAt(0)).toBe(false); + }); + + it('should return false when neither visible nor active', () => { + mesh.setVisibilityAt(0, false); + mesh.setActiveAt(0, false); + + expect(mesh.getActiveAndVisibilityAt(0)).toBe(false); + }); + }); + + describe('setActiveAndVisibilityAt', () => { + it('should set both active and visible to true', () => { + mesh.setVisibilityAt(0, false); + mesh.setActiveAt(0, false); + + mesh.setActiveAndVisibilityAt(0, true); + + expect(mesh.getVisibilityAt(0)).toBe(true); + expect(mesh.getActiveAt(0)).toBe(true); + }); + + it('should set both active and visible to false', () => { + mesh.setActiveAndVisibilityAt(0, false); + + expect(mesh.getVisibilityAt(0)).toBe(false); + expect(mesh.getActiveAt(0)).toBe(false); + }); + }); + + describe('with entities enabled', () => { + let meshWithEntities: InstancedMesh2; + + beforeEach(() => { + meshWithEntities = createTestInstancedMeshWithEntities(100); + meshWithEntities.addInstances(10, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + it('should get visibility via instances[i].visible', () => { + expect(meshWithEntities.instances[0].visible).toBe(true); + }); + + it('should set visibility via instances[i].visible', () => { + meshWithEntities.instances[3].visible = false; + + expect(meshWithEntities.getVisibilityAt(3)).toBe(false); + }); + + it('should sync visibility between property and method', () => { + meshWithEntities.setVisibilityAt(5, false); + + expect(meshWithEntities.instances[5].visible).toBe(false); + }); + + it('should get active state via instances[i].active', () => { + expect(meshWithEntities.instances[0].active).toBe(true); + }); + + it('should set active state via instances[i].active', () => { + meshWithEntities.instances[3].active = false; + + expect(meshWithEntities.getActiveAt(3)).toBe(false); + }); + }); +}); + +describe('Per-instance Opacity', () => { + let mesh: InstancedMesh2; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + mesh.addInstances(10, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + describe('setOpacityAt / getOpacityAt', () => { + it('should default to 1 when opacity not initialized', () => { + expect(mesh.getOpacityAt(0)).toBe(1); + }); + + it('should set opacity value', () => { + mesh.setOpacityAt(3, 0.5); + + expect(mesh.getOpacityAt(3)).toBe(0.5); + }); + + it('should set opacity to 0', () => { + mesh.setOpacityAt(0, 0); + + expect(mesh.getOpacityAt(0)).toBe(0); + }); + + it('should set opacity to 1', () => { + mesh.setOpacityAt(0, 0.5); + mesh.setOpacityAt(0, 1); + + expect(mesh.getOpacityAt(0)).toBe(1); + }); + + it('should not affect other instances', () => { + mesh.setOpacityAt(5, 0.3); + + expect(mesh.getOpacityAt(4)).toBe(1); + expect(mesh.getOpacityAt(6)).toBe(1); + }); + + it('should initialize colorsTexture when first opacity is set', () => { + // Colors texture may or may not be initialized depending on setup + const hadColorsTexture = mesh.colorsTexture !== null; + + mesh.setOpacityAt(0, 0.5); + + expect(mesh.colorsTexture).not.toBeNull(); + + // If we didn't have it before, it should be created now + if (!hadColorsTexture) { + expect(mesh['_useOpacity']).toBe(true); + } + }); + }); + + describe('with entities enabled', () => { + let meshWithEntities: InstancedMesh2; + + beforeEach(() => { + meshWithEntities = createTestInstancedMeshWithEntities(100); + meshWithEntities.addInstances(10, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + it('should get opacity via instances[i].opacity', () => { + expect(meshWithEntities.instances[0].opacity).toBe(1); + }); + + it('should set opacity via instances[i].opacity', () => { + meshWithEntities.instances[3].opacity = 0.7; + + expect(meshWithEntities.getOpacityAt(3)).toBeCloseTo(0.7, 5); + }); + + it('should sync opacity between property and method', () => { + meshWithEntities.setOpacityAt(5, 0.4); + + expect(meshWithEntities.instances[5].opacity).toBeCloseTo(0.4, 5); + }); + }); + + describe('opacity edge cases', () => { + it('should handle very small opacity values', () => { + mesh.setOpacityAt(0, 0.001); + + expect(mesh.getOpacityAt(0)).toBeCloseTo(0.001, 5); + }); + + it('should handle multiple opacity changes', () => { + mesh.setOpacityAt(0, 0.2); + mesh.setOpacityAt(0, 0.8); + mesh.setOpacityAt(0, 0.5); + + expect(mesh.getOpacityAt(0)).toBe(0.5); + }); + + it('should set opacity on multiple instances', () => { + for (let i = 0; i < 5; i++) { + mesh.setOpacityAt(i, i * 0.2); + } + + expect(mesh.getOpacityAt(0)).toBe(0); + expect(mesh.getOpacityAt(1)).toBeCloseTo(0.2, 5); + expect(mesh.getOpacityAt(2)).toBeCloseTo(0.4, 5); + expect(mesh.getOpacityAt(3)).toBeCloseTo(0.6, 5); + expect(mesh.getOpacityAt(4)).toBeCloseTo(0.8, 5); + }); + }); +}); + +describe('Per-instance Color', () => { + let mesh: InstancedMesh2; + + beforeEach(() => { + mesh = createTestInstancedMesh({ capacity: 100 }); + mesh.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + describe('setColorAt / getColorAt', () => { + it('should set color using hex value', () => { + mesh.setColorAt(0, 0xff0000); + + const color = mesh.getColorAt(0); + expect(color.r).toBeCloseTo(1, 5); + expect(color.g).toBeCloseTo(0, 5); + expect(color.b).toBeCloseTo(0, 5); + }); + + it('should set color using Color object', () => { + mesh.setColorAt(0, new Color(0, 1, 0)); + + const color = mesh.getColorAt(0); + expect(color.r).toBeCloseTo(0, 5); + expect(color.g).toBeCloseTo(1, 5); + expect(color.b).toBeCloseTo(0, 5); + }); + + it('should set different colors on different instances', () => { + mesh.setColorAt(0, 0xff0000); + mesh.setColorAt(1, 0x00ff00); + mesh.setColorAt(2, 0x0000ff); + + expect(mesh.getColorAt(0).r).toBeCloseTo(1, 5); + expect(mesh.getColorAt(1).g).toBeCloseTo(1, 5); + expect(mesh.getColorAt(2).b).toBeCloseTo(1, 5); + }); + + it('should initialize colorsTexture when first color is set', () => { + const hadColorsTexture = mesh.colorsTexture !== null; + + mesh.setColorAt(0, 0xff0000); + + expect(mesh.colorsTexture).not.toBeNull(); + }); + }); + + describe('with entities enabled', () => { + let meshWithEntities: InstancedMesh2; + + beforeEach(() => { + meshWithEntities = createTestInstancedMeshWithEntities(100); + meshWithEntities.addInstances(5, (obj, index) => { + obj.position.set(index, 0, 0); + }); + }); + + it('should set color via instances[i].color', () => { + meshWithEntities.instances[0].color = 0xff0000; + + const color = meshWithEntities.getColorAt(0); + expect(color.r).toBeCloseTo(1, 5); + }); + + it('should get color via instances[i].color', () => { + meshWithEntities.setColorAt(0, 0x00ff00); + + const color = meshWithEntities.instances[0].color; + expect(color.g).toBeCloseTo(1, 5); + }); + }); +}); + diff --git a/tests/fixtures/test-scene.html b/tests/fixtures/test-scene.html new file mode 100644 index 0000000..80b09c7 --- /dev/null +++ b/tests/fixtures/test-scene.html @@ -0,0 +1,150 @@ + + + + + + InstancedMesh2 Test Scene + + + +
+
Instances: 0
+
Rendered: 0
+
FPS: 0
+
+ + + + + + + diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..43d1db5 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,109 @@ +/** + * Test setup utilities for @three.ez/instanced-mesh + * + * Provides helpers to create InstancedMesh2 instances for testing + * without requiring a full WebGL context. + */ + +import { BoxGeometry, ColorManagement, MeshBasicMaterial, WebGLRenderer } from 'three'; +import { InstancedMesh2 } from '../src/core/InstancedMesh2.common'; +import { SquareDataTexture } from '../src/core/utils/SquareDataTexture'; + +// Import feature modules to ensure prototype extensions are applied +import '../src/core/feature/Capacity'; +import '../src/core/feature/Instances'; +import '../src/core/feature/FrustumCulling'; +import '../src/core/feature/LOD'; +import '../src/core/feature/Raycasting'; + +/** + * Creates a mock WebGL2 rendering context for testing + */ +export function createMockGL(): WebGL2RenderingContext { + return { + UNSIGNED_INT: 5125, + TEXTURE_2D: 3553, + ARRAY_BUFFER: 34962, + STATIC_DRAW: 35044, + DYNAMIC_DRAW: 35048, + NONE: 0, + BROWSER_DEFAULT_WEBGL: 37444, + UNPACK_FLIP_Y_WEBGL: 37440, + UNPACK_PREMULTIPLY_ALPHA_WEBGL: 37441, + UNPACK_ALIGNMENT: 3317, + UNPACK_COLORSPACE_CONVERSION_WEBGL: 37443, + createBuffer: () => ({}), + bindBuffer: () => {}, + bufferData: () => {}, + bufferSubData: () => {}, + deleteBuffer: () => {}, + pixelStorei: () => {}, + bindTexture: () => {}, + texSubImage2D: () => {}, + } as unknown as WebGL2RenderingContext; +} + +/** + * Creates a mock WebGLRenderer for testing + */ +export function createMockRenderer(): WebGLRenderer { + const gl = createMockGL(); + + return { + getContext: () => gl, + properties: { + get: () => ({ __webglTexture: {} }), + }, + state: { + bindTexture: () => {}, + }, + extensions: {}, + capabilities: {}, + } as unknown as WebGLRenderer; +} + +/** + * Creates an InstancedMesh2 configured for testing + */ +export function createTestInstancedMesh(options: { + capacity?: number; + createEntities?: boolean; + allowsEuler?: boolean; +} = {}): InstancedMesh2 { + const { capacity = 100, createEntities = false, allowsEuler = false } = options; + + const geometry = new BoxGeometry(1, 1, 1); + const material = new MeshBasicMaterial({ color: 0xff0000 }); + const renderer = createMockRenderer(); + + const mesh = new InstancedMesh2(geometry, material, { + capacity, + createEntities, + allowsEuler, + renderer, + }); + + // Initialize matrices texture for testing + if (!mesh.matricesTexture) { + mesh.matricesTexture = new SquareDataTexture(Float32Array, 4, 4, capacity); + } + + // Setup initColorsTexture method for testing + mesh.initColorsTexture = function() { + if (!this.colorsTexture) { + this.colorsTexture = new SquareDataTexture(Float32Array, 4, 1, this._capacity); + this.colorsTexture.colorSpace = ColorManagement.workingColorSpace; + this.colorsTexture._data.fill(1); + } + }; + + return mesh; +} + +/** + * Creates an InstancedMesh2 with entities enabled for testing + */ +export function createTestInstancedMeshWithEntities(capacity = 100): InstancedMesh2 { + return createTestInstancedMesh({ capacity, createEntities: true }); +} + diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..c1e2c62 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "noImplicitAny": false, + "strictNullChecks": false, + "types": [ + "vitest/globals", + "vite-plugin-glsl/ext" + ], + "baseUrl": ".", + "paths": { + "@three.ez/instanced-mesh": ["./src/index.ts"] + } + }, + "include": [ + "tests/**/*.ts", + "src/**/*.ts" + ] +} + diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ee05f9e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; +import glsl from 'vite-plugin-glsl'; + +export default defineConfig({ + plugins: [glsl()], + test: { + environment: 'happy-dom', + include: ['tests/**/*.test.ts'], + exclude: ['tests/e2e/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.d.ts', 'src/shaders/**'] + }, + globals: true, + typecheck: { + tsconfig: './tsconfig.test.json' + } + }, + resolve: { + alias: { + '@three.ez/instanced-mesh': resolve(__dirname, 'src/index.ts') + } + } +}); + From d35fc19060be341698f74e8bb20a6e7ea45424fc Mon Sep 17 00:00:00 2001 From: Glavin Wiechert Date: Wed, 3 Dec 2025 22:16:18 -0400 Subject: [PATCH 2/6] Refactor E2E tests for frustum culling, LOD switching, and rendering to improve clarity and performance. Remove unnecessary texture initialization calls and enhance assertions for texture existence and data validation. --- .gitignore | 1 + package.json | 7 +++- playwright.config.ts | 4 +-- tests/e2e/frustum-culling.spec.ts | 25 +++++++------- tests/e2e/lod-switching.spec.ts | 8 ----- tests/e2e/rendering.spec.ts | 44 +++++++++++++++---------- tests/features/bvh.test.ts | 2 +- tests/features/dynamic-capacity.test.ts | 2 +- tests/features/frustum-culling.test.ts | 2 +- tests/features/lod.test.ts | 9 ++--- tests/features/sorting.test.ts | 2 +- tests/features/visibility.test.ts | 2 +- tests/fixtures/test-scene.html | 20 +---------- tests/setup.ts | 2 +- 14 files changed, 60 insertions(+), 70 deletions(-) diff --git a/.gitignore b/.gitignore index 65620a8..6986397 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist # Tests .last-run.json playwright-report/ +test-results/ diff --git a/package.json b/package.json index 6169d97..77a3d1b 100644 --- a/package.json +++ b/package.json @@ -56,11 +56,15 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.57.0", "@stylistic/eslint-plugin": "^5.6.1", "@three.ez/main": "^0.5.11", "@types/three": "^0.181.0", + "@vitest/coverage-v8": "^4.0.15", "eslint": "^9.39.1", + "happy-dom": "^20.0.11", "meshoptimizer": "^0.25.0", + "playwright": "^1.57.0", "simplex-noise": "^4.0.3", "three-hex-tiling": "^0.1.5", "typescript": "^5.9.3", @@ -68,7 +72,8 @@ "vite": "^7.2.6", "vite-plugin-externalize-deps": "^0.10.0", "vite-plugin-glsl": "^1.5.5", - "vite-plugin-static-copy": "^3.1.4" + "vite-plugin-static-copy": "^3.1.4", + "vitest": "^4.0.15" }, "peerDependencies": { "three": ">=0.159.0" diff --git a/playwright.config.ts b/playwright.config.ts index 5473556..b18c6a4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,9 +13,9 @@ export default defineConfig({ }, projects: [ { - name: 'webkit', + name: 'chromium', use: { - ...devices['Desktop Safari'], + ...devices['Desktop Chrome'], }, }, ], diff --git a/tests/e2e/frustum-culling.spec.ts b/tests/e2e/frustum-culling.spec.ts index a1a267d..a5c5809 100644 --- a/tests/e2e/frustum-culling.spec.ts +++ b/tests/e2e/frustum-culling.spec.ts @@ -48,19 +48,23 @@ test.describe('Frustum Culling E2E', () => { }); test('should respect perObjectFrustumCulled setting', async ({ page }) => { - await page.evaluate(() => { + const result = await page.evaluate(() => { const mesh = window.createTestMesh({ count: 50, spread: 1000 }); + + // Disable auto update to prevent render loop from re-running culling + mesh.autoUpdate = false; mesh.perObjectFrustumCulled = false; mesh.performFrustumCulling(window.camera); + + // Get values immediately after performFrustumCulling + return { + renderCount: mesh.count, + totalCount: mesh.instancesCount + }; }); - await page.waitForTimeout(100); - - // With culling disabled, all instances should be rendered - const renderCount = await page.evaluate(() => window.testMesh.count); - const totalCount = await page.evaluate(() => window.testMesh.instancesCount); - - expect(renderCount).toBe(totalCount); + // With perObjectFrustumCulled disabled, all instances should be rendered + expect(result.renderCount).toBe(result.totalCount); }); test('should update culling when camera moves', async ({ page }) => { @@ -155,7 +159,6 @@ test.describe('Deterministic Frustum Culling', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); // Camera at (0, 0, 50) looking at origin window.camera.position.set(0, 0, 50); @@ -216,7 +219,6 @@ test.describe('Deterministic Frustum Culling', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); // Camera at origin looking at -Z with specific near/far window.camera.position.set(0, 0, 0); @@ -274,7 +276,6 @@ test.describe('Deterministic Frustum Culling', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); // Camera with known FOV to calculate exact frustum boundaries window.camera.position.set(0, 0, 0); @@ -339,7 +340,6 @@ test.describe('Deterministic Frustum Culling', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); // Camera setup window.camera.position.set(0, 0, 50); @@ -403,7 +403,6 @@ test.describe('Deterministic Frustum Culling', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); // Camera looking at origin window.camera.position.set(0, 0, 50); diff --git a/tests/e2e/lod-switching.spec.ts b/tests/e2e/lod-switching.spec.ts index 9418f2e..479d28b 100644 --- a/tests/e2e/lod-switching.spec.ts +++ b/tests/e2e/lod-switching.spec.ts @@ -26,7 +26,6 @@ test.describe('LOD Switching E2E', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); mesh.addLOD(midGeometry, material, 50); mesh.addLOD(lowGeometry, material, 100); @@ -60,7 +59,6 @@ test.describe('LOD Switching E2E', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); mesh.addLOD(lowGeometry, material, 50); // Create instances at known distances @@ -110,7 +108,6 @@ test.describe('LOD Switching E2E', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); mesh.addLOD(lowGeometry, material, 30); // All instances at origin @@ -168,7 +165,6 @@ test.describe('LOD Switching E2E', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); mesh.addShadowLOD(shadowGeometry, 0); window.testMesh = mesh; @@ -208,7 +204,6 @@ test.describe('Deterministic LOD Assignment', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); mesh.addLOD(lowGeometry, material, 50); // LOD 1 at 50 units distance // Position camera at origin looking at -Z with wide FOV @@ -269,7 +264,6 @@ test.describe('Deterministic LOD Assignment', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); mesh.addLOD(lowGeometry, material, 50); // LOD 1 at 50 units // All 5 instances at origin @@ -327,7 +321,6 @@ test.describe('Deterministic LOD Assignment', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); mesh.addLOD(lowGeometry, material, 50); // Boundary at 50 units // Camera at origin @@ -384,7 +377,6 @@ test.describe('Deterministic LOD Assignment', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); mesh.addLOD(midGeometry, material, 30); // LOD 1 at 30 units mesh.addLOD(lowGeometry, material, 60); // LOD 2 at 60 units diff --git a/tests/e2e/rendering.spec.ts b/tests/e2e/rendering.spec.ts index 27e36e2..ffb1191 100644 --- a/tests/e2e/rendering.spec.ts +++ b/tests/e2e/rendering.spec.ts @@ -86,9 +86,8 @@ test.describe('Rendering E2E', () => { const result = await page.evaluate(() => { const mesh = window.createTestMesh({ count: 5, spread: 10 }); - // Verify colorsTexture exists after mesh creation - const hasTexture = mesh.colorsTexture !== null; - const textureHasData = hasTexture && mesh.colorsTexture._data.length > 0; + // colorsTexture is lazily initialized - should be null before setting any colors + const hasTextureBeforeSet = mesh.colorsTexture !== null; // Set different colors using Color objects (should not throw) let setColorSuccess = false; @@ -102,6 +101,10 @@ test.describe('Rendering E2E', () => { setColorSuccess = false; } + // After setting colors, colorsTexture should exist + const hasTextureAfterSet = mesh.colorsTexture !== null; + const textureHasData = hasTextureAfterSet && mesh.colorsTexture._data.length > 0; + // Verify getColorAt doesn't throw let getColorSuccess = false; try { @@ -114,14 +117,17 @@ test.describe('Rendering E2E', () => { } return { - hasTexture, + hasTextureBeforeSet, + hasTextureAfterSet, textureHasData, setColorSuccess, getColorSuccess }; }); - expect(result.hasTexture).toBe(true); + // colorsTexture is lazily initialized - only created when setColorAt is called + expect(result.hasTextureBeforeSet).toBe(false); + expect(result.hasTextureAfterSet).toBe(true); expect(result.textureHasData).toBe(true); expect(result.setColorSuccess).toBe(true); expect(result.getColorSuccess).toBe(true); @@ -162,7 +168,6 @@ test.describe('Rendering E2E', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); window.scene.add(mesh); window.testMesh = mesh; @@ -219,11 +224,18 @@ test.describe('Rendering Pipeline Verification', () => { // Force a render window.renderer.render(window.scene, window.camera); - // Verify the texture exists and has data for the shader + // Verify the matricesTexture exists and has data for the shader const hasMatricesTexture = mesh.matricesTexture !== null; const matricesTextureHasData = hasMatricesTexture && mesh.matricesTexture._data.length > 0; - const hasColorsTexture = mesh.colorsTexture !== null; - const colorsTextureHasData = hasColorsTexture && mesh.colorsTexture._data.length > 0; + + // colorsTexture is lazily initialized - only exists after setColorAt is called + // So we don't expect it to exist without setting colors + const hasColorsTextureBeforeSet = mesh.colorsTexture !== null; + + // Set a color to initialize the colorsTexture + mesh.setColorAt(0, 0xff0000); + const hasColorsTextureAfterSet = mesh.colorsTexture !== null; + const colorsTextureHasData = hasColorsTextureAfterSet && mesh.colorsTexture._data.length > 0; // Verify the texture has the correct structure for shader binding const textureWidth = mesh.matricesTexture?.image?.width ?? 0; @@ -232,7 +244,8 @@ test.describe('Rendering Pipeline Verification', () => { return { hasMatricesTexture, matricesTextureHasData, - hasColorsTexture, + hasColorsTextureBeforeSet, + hasColorsTextureAfterSet, colorsTextureHasData, textureWidth, textureHeight, @@ -242,7 +255,10 @@ test.describe('Rendering Pipeline Verification', () => { expect(result.hasMatricesTexture).toBe(true); expect(result.matricesTextureHasData).toBe(true); - expect(result.hasColorsTexture).toBe(true); + // colorsTexture is lazily initialized + expect(result.hasColorsTextureBeforeSet).toBe(false); + expect(result.hasColorsTextureAfterSet).toBe(true); + expect(result.colorsTextureHasData).toBe(true); expect(result.hasValidDimensions).toBe(true); }); @@ -281,7 +297,6 @@ test.describe('Rendering Pipeline Verification', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); // Add instances with known positions mesh.addInstances(3, (obj, index) => { @@ -330,7 +345,6 @@ test.describe('Rendering Pipeline Verification', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); mesh.addLOD(lowGeo, material, 50); mesh.addInstances(5, (obj, index) => { @@ -409,7 +423,6 @@ test.describe('Rendering Pipeline Verification', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); // Add instance with initial position mesh.addInstances(1, (obj, index) => { @@ -454,7 +467,6 @@ test.describe('Rendering Pipeline Verification', () => { renderer: window.renderer }); - mesh.initMatricesTexture(); // Camera at z=50 looking at origin window.camera.position.set(0, 0, 50); @@ -578,7 +590,6 @@ test.describe('Rendering Output Verification', () => { capacity: 10, renderer: window.renderer }); - mesh.initMatricesTexture(); // All instances BEHIND camera (positive Z) mesh.addInstances(10, (obj, i) => { @@ -625,7 +636,6 @@ test.describe('Rendering Output Verification', () => { capacity: 10, renderer: window.renderer }); - mesh.initMatricesTexture(); // All instances IN FRONT of camera mesh.addInstances(10, (obj, i) => { diff --git a/tests/features/bvh.test.ts b/tests/features/bvh.test.ts index 8984843..4d3c1b3 100644 --- a/tests/features/bvh.test.ts +++ b/tests/features/bvh.test.ts @@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { Box3, Matrix4, Vector3 } from 'three'; import { createTestInstancedMesh } from '../setup'; -import { InstancedMesh2 } from '../../src/core/InstancedMesh2.common'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2'; describe('BVH Spatial Indexing', () => { let mesh: InstancedMesh2; diff --git a/tests/features/dynamic-capacity.test.ts b/tests/features/dynamic-capacity.test.ts index b86e8f1..d8ae33c 100644 --- a/tests/features/dynamic-capacity.test.ts +++ b/tests/features/dynamic-capacity.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createTestInstancedMesh, createTestInstancedMeshWithEntities } from '../setup'; -import { InstancedMesh2 } from '../../src/core/InstancedMesh2.common'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2'; describe('Dynamic Capacity', () => { let mesh: InstancedMesh2; diff --git a/tests/features/frustum-culling.test.ts b/tests/features/frustum-culling.test.ts index 7f2dd67..2785e36 100644 --- a/tests/features/frustum-culling.test.ts +++ b/tests/features/frustum-culling.test.ts @@ -14,7 +14,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { PerspectiveCamera } from 'three'; import { createTestInstancedMesh } from '../setup'; -import { InstancedMesh2 } from '../../src/core/InstancedMesh2.common'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2'; describe('Frustum Culling', () => { let mesh: InstancedMesh2; diff --git a/tests/features/lod.test.ts b/tests/features/lod.test.ts index e6c1f4d..af09562 100644 --- a/tests/features/lod.test.ts +++ b/tests/features/lod.test.ts @@ -14,7 +14,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { BoxGeometry, MeshBasicMaterial, SphereGeometry } from 'three'; import { createTestInstancedMesh } from '../setup'; -import { InstancedMesh2 } from '../../src/core/InstancedMesh2.common'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2'; describe('Level of Detail (LOD)', () => { let mesh: InstancedMesh2; @@ -39,7 +39,7 @@ describe('Level of Detail (LOD)', () => { }); it('should set first LOD with default values', () => { - mesh.setFirstLODDistance(); + mesh.setFirstLODDistance(0); const firstLevel = mesh.LODinfo.render.levels[0]; expect(firstLevel.distance).toBe(0); @@ -48,11 +48,12 @@ describe('Level of Detail (LOD)', () => { }); it('should set first LOD with custom distance', () => { - mesh.setFirstLODDistance(100, 0.1); + mesh.setFirstLODDistance(100); const firstLevel = mesh.LODinfo.render.levels[0]; expect(firstLevel.distance).toBe(100); - expect(firstLevel.hysteresis).toBe(0.1); + // Note: hysteresis is always 0 at first level, as per implementation + expect(firstLevel.hysteresis).toBe(0); }); it('should be chainable', () => { diff --git a/tests/features/sorting.test.ts b/tests/features/sorting.test.ts index f3a3c12..ebf9f54 100644 --- a/tests/features/sorting.test.ts +++ b/tests/features/sorting.test.ts @@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { MeshBasicMaterial } from 'three'; import { createTestInstancedMesh } from '../setup'; -import { InstancedMesh2 } from '../../src/core/InstancedMesh2.common'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2'; import { createRadixSort, sortOpaque, sortTransparent } from '../../src/utils/SortingUtils'; import { InstancedRenderItem } from '../../src/core/utils/InstancedRenderList'; diff --git a/tests/features/visibility.test.ts b/tests/features/visibility.test.ts index c89fd1f..54ca550 100644 --- a/tests/features/visibility.test.ts +++ b/tests/features/visibility.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { Color } from 'three'; import { createTestInstancedMesh, createTestInstancedMeshWithEntities } from '../setup'; -import { InstancedMesh2 } from '../../src/core/InstancedMesh2.common'; +import { InstancedMesh2 } from '../../src/core/InstancedMesh2'; describe('Per-instance Visibility', () => { let mesh: InstancedMesh2; diff --git a/tests/fixtures/test-scene.html b/tests/fixtures/test-scene.html index 80b09c7..9d6b5f9 100644 --- a/tests/fixtures/test-scene.html +++ b/tests/fixtures/test-scene.html @@ -26,24 +26,10 @@
FPS: 0
- -