diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..15876a5 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,71 @@ +name: PR Preview + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + deployments: write + +concurrency: + group: preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + preview: + name: Deploy PR Preview + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - name: Build + env: + VITE_GOOGLE_CLIENT_ID: ${{ secrets.VITE_GOOGLE_CLIENT_ID }} + VITE_GITHUB_CLIENT_ID: ${{ secrets.VITE_GITHUB_CLIENT_ID }} + run: npm run build + - name: Publish preview to Cloudflare Pages + id: deploy + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy dist --project-name=sinter --branch=${{ github.head_ref }} + - name: Comment preview URL on the PR + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const aliasUrl = `${{ steps.deploy.outputs.pages-deployment-alias-url }}`; + const deploymentUrl = `${{ steps.deploy.outputs.deployment-url }}`; + const url = aliasUrl || deploymentUrl; + const body = `${marker}\nšŸš€ Preview deployed: ${url}\n\n(updates on every push to this PR)`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find((c) => c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/.gitignore b/.gitignore index 7c1cad8..42e10a2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ data/ .env test-results/ playwright-report/ +coverage/ .env.swp diff --git a/package-lock.json b/package-lock.json index 4880c9f..e8b7996 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@types/three": "^0.170.0", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^4.1.10", "better-sqlite3": "^12.8.0", "dotenv": "^17.4.2", "jsdom": "^29.0.2", @@ -228,9 +229,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -238,9 +239,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -272,13 +273,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -364,19 +365,29 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1155,9 +1166,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1172,9 +1180,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1189,9 +1194,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1206,9 +1208,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1223,9 +1222,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1240,9 +1236,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1257,9 +1250,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1274,9 +1264,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1291,9 +1278,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1308,9 +1292,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1325,9 +1306,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1342,9 +1320,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1359,9 +1334,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1592,9 +1564,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1612,9 +1581,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1632,9 +1598,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1652,9 +1615,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2036,17 +1996,48 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2055,13 +2046,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", - "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.4", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2082,9 +2073,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2095,13 +2086,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -2109,14 +2100,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2125,9 +2116,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -2135,13 +2126,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2201,6 +2192,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2744,6 +2754,16 @@ "dev": true, "license": "ISC" }, + "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-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2757,6 +2777,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2819,6 +2846,45 @@ "dev": true, "license": "MIT" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "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-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -3068,9 +3134,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3092,9 +3155,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3116,9 +3176,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3140,9 +3197,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3250,6 +3304,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -3889,6 +3984,19 @@ "node": ">=0.10.0" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -4718,19 +4826,19 @@ } }, "node_modules/vitest": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", - "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.4", - "@vitest/mocker": "4.1.4", - "@vitest/pretty-format": "4.1.4", - "@vitest/runner": "4.1.4", - "@vitest/snapshot": "4.1.4", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4758,12 +4866,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.4", - "@vitest/browser-preview": "4.1.4", - "@vitest/browser-webdriverio": "4.1.4", - "@vitest/coverage-istanbul": "4.1.4", - "@vitest/coverage-v8": "4.1.4", - "@vitest/ui": "4.1.4", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index e3e8a81..e5072d2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", "test:all": "vitest run && playwright test", "migrate": "tsx server/migrate-to-static.ts" @@ -36,6 +37,7 @@ "@types/three": "^0.170.0", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^4.1.10", "better-sqlite3": "^12.8.0", "dotenv": "^17.4.2", "jsdom": "^29.0.2", diff --git a/src/store/modelerStore.test.ts b/src/store/modelerStore.test.ts index 3815d0d..5bb6bff 100644 --- a/src/store/modelerStore.test.ts +++ b/src/store/modelerStore.test.ts @@ -361,6 +361,370 @@ describe('Modeler editing scenarios', () => { }); }); + // ─── Scenario 13: Expanded node tracking ────────────────────────── + describe('Scenario: Expand/collapse tree nodes', () => { + it('toggles a single node expanded state', () => { + getState().toggleExpanded('a'); + expect(getState().expandedNodes.has('a')).toBe(true); + getState().toggleExpanded('a'); + expect(getState().expandedNodes.has('a')).toBe(false); + }); + + it('expandAll expands every node with children or expected children', () => { + getState().addPrimitive('box'); + getState().selectNode(getState().tree!.id); + getState().wrapSelected('shell'); + const shellId = getState().tree!.id; + const boxId = getState().tree!.children[0].id; + + getState().expandAll(); + expect(getState().expandedNodes.has(shellId)).toBe(true); + expect(getState().expandedNodes.has(boxId)).toBe(false); + }); + + it('expandAll is a no-op with no tree', () => { + getState().expandAll(); + expect(getState().expandedNodes.size).toBe(0); + }); + + it('collapseAll clears expanded nodes', () => { + getState().toggleExpanded('a'); + getState().toggleExpanded('b'); + getState().collapseAll(); + expect(getState().expandedNodes.size).toBe(0); + }); + + it('selectNode auto-expands ancestors of the selected node', () => { + getState().addPrimitive('box'); + getState().selectNode(getState().tree!.id); + getState().wrapSelected('shell'); + const shellId = getState().tree!.id; + const boxId = getState().tree!.children[0].id; + + getState().collapseAll(); + getState().selectNode(boxId); + expect(getState().expandedNodes.has(shellId)).toBe(true); + expect(getState().selectedNodeId).toBe(boxId); + }); + + it('selectNode(null) just clears selection', () => { + getState().addPrimitive('box'); + getState().selectNode(getState().tree!.id); + getState().selectNode(null); + expect(getState().selectedNodeId).toBeNull(); + }); + }); + + // ─── Scenario 14: Add child to selected node directly ───────────── + describe('Scenario: Add child to selected node', () => { + it('adds a child under the selected node and selects it', () => { + getState().addPrimitive('box'); + getState().selectNode(getState().tree!.id); + getState().wrapSelected('union'); // union(box) — invalid but fine for the test + const unionId = getState().tree!.id; + getState().selectNode(unionId); + getState().addChildToSelected('sphere'); + + const union = getState().tree!; + expect(union.children).toHaveLength(2); + expect(union.children[1].kind).toBe('sphere'); + expect(getState().selectedNodeId).toBe(union.children[1].id); + expect(getState().expandedNodes.has(unionId)).toBe(true); + }); + + it('is a no-op with no tree or no selection', () => { + getState().addChildToSelected('box'); + expect(getState().tree).toBeNull(); + + getState().addPrimitive('box'); + getState().selectNode(null); + getState().addChildToSelected('sphere'); + expect(getState().tree!.kind).toBe('box'); + }); + }); + + // ─── Scenario 15: Node data updates (e.g. text content) ─────────── + describe('Scenario: updateNodeData', () => { + it('merges data fields onto a node', () => { + getState().addPrimitive('text'); + const id = getState().tree!.id; + getState().updateNodeData(id, { text: 'Hello' }); + expect(getState().tree!.data?.text).toBe('Hello'); + }); + + it('is a no-op with no tree', () => { + getState().updateNodeData('missing', { text: 'x' }); + expect(getState().tree).toBeNull(); + }); + }); + + // ─── Scenario 16: Misc setters ───────────────────────────────────── + describe('Scenario: Mesh/display/evaluating/error setters', () => { + it('sets mesh, sdfDisplay, evaluating and error', () => { + const mesh = { positions: new Float32Array(), normals: new Float32Array(), indices: new Uint32Array() } as any; + getState().setMesh(mesh); + expect(getState().mesh).toBe(mesh); + + const display = { glsl: 'x', paramCount: 0, paramValues: [], textures: [], bbMin: [0, 0, 0], bbMax: [1, 1, 1], hasWarn: false } as any; + getState().setSDFDisplay(display); + expect(getState().sdfDisplay).toBe(display); + + getState().setEvaluating(true); + expect(getState().evaluating).toBe(true); + + getState().setError('boom'); + expect(getState().error).toBe('boom'); + + getState().setMesh(null); + expect(getState().mesh).toBeNull(); + getState().setSDFDisplay(null); + expect(getState().sdfDisplay).toBeNull(); + getState().setError(null); + expect(getState().error).toBeNull(); + }); + }); + + // ─── Scenario 17: addNodeFromData drag-and-drop paths ───────────── + describe('Scenario: addNodeFromData', () => { + it('becomes root when there is no tree', () => { + getState().addNodeFromData(null, { kind: 'box', params: { width: 5, height: 5, depth: 5 } }); + expect(getState().tree!.kind).toBe('box'); + }); + + it('unions a dropped primitive with the root when no target is given', () => { + getState().addPrimitive('box'); + getState().addNodeFromData(null, { kind: 'sphere', params: { radius: 3 } }); + expect(getState().tree!.kind).toBe('union'); + expect(getState().tree!.children).toHaveLength(2); + }); + + it('ignores a dropped operation with no target on an existing tree', () => { + getState().addPrimitive('box'); + getState().addNodeFromData(null, { kind: 'shell', params: { thickness: 2 } }); + expect(getState().tree!.kind).toBe('box'); + }); + + it('does nothing if the target node cannot be found', () => { + getState().addPrimitive('box'); + getState().addNodeFromData('does-not-exist', { kind: 'sphere', params: { radius: 3 } }); + expect(getState().tree!.kind).toBe('box'); + }); + + it('wraps a primitive target when an operation is dropped on it', () => { + getState().addPrimitive('box'); + const boxId = getState().tree!.id; + getState().addNodeFromData(boxId, { kind: 'shell', params: { thickness: 2 } }); + expect(getState().tree!.kind).toBe('shell'); + expect(getState().tree!.children[0].kind).toBe('box'); + }); + + it('wraps two primitives in a union when dropped on each other', () => { + getState().addPrimitive('box'); + const boxId = getState().tree!.id; + getState().addNodeFromData(boxId, { kind: 'sphere', params: { radius: 3 } }); + expect(getState().tree!.kind).toBe('union'); + expect(getState().tree!.children[0].kind).toBe('box'); + expect(getState().tree!.children[1].kind).toBe('sphere'); + }); + + it('adds as a child when the target operation has room', () => { + getState().addPrimitive('box'); + const boxId = getState().tree!.id; + getState().selectNode(boxId); + getState().wrapSelected('subtract'); // subtract(box) has room for one more child + const subId = getState().tree!.id; + getState().addNodeFromData(subId, { kind: 'sphere', params: { radius: 3 } }); + expect(getState().tree!.children).toHaveLength(2); + expect(getState().tree!.children[1].kind).toBe('sphere'); + }); + + it('wraps a full operation target when another operation is dropped on it', () => { + getState().addPrimitive('box'); + getState().addPrimitive('sphere'); // union(box, sphere) — full + const unionId = getState().tree!.id; + getState().addNodeFromData(unionId, { kind: 'shell', params: { thickness: 2 } }); + expect(getState().tree!.kind).toBe('shell'); + expect(getState().tree!.children[0].kind).toBe('union'); + }); + + it('fills an empty slot when a primitive is dropped on a full operation', () => { + getState().addPrimitive('box'); + getState().addPrimitive('sphere'); // union(box, sphere) + const unionId = getState().tree!.id; + getState().removeNode(getState().tree!.children[0].id); // union(_empty, sphere) + getState().addNodeFromData(unionId, { kind: 'cylinder', params: { radius: 2, height: 4 } }); + expect(getState().tree!.children[0].kind).toBe('cylinder'); + expect(getState().tree!.children[1].kind).toBe('sphere'); + }); + + it('hydrates nested children data recursively', () => { + getState().addNodeFromData(null, { + kind: 'shell', + params: { thickness: 2 }, + children: [{ kind: 'box', params: { width: 1, height: 1, depth: 1 } }], + }); + expect(getState().tree!.kind).toBe('shell'); + expect(getState().tree!.children[0].kind).toBe('box'); + }); + }); + + // ─── Scenario 18: pasteToSelected ────────────────────────────────── + describe('Scenario: pasteToSelected', () => { + it('is a no-op with an empty clipboard', () => { + getState().pasteToSelected(); + expect(getState().tree).toBeNull(); + }); + + it('pastes as root when there is no tree', () => { + getState().addPrimitive('box'); + getState().selectNode(getState().tree!.id); + getState().copySelected(); + getState().removeNode(getState().tree!.id); + expect(getState().tree).toBeNull(); + + getState().pasteToSelected(); + expect(getState().tree!.kind).toBe('box'); + expect(getState().selectedNodeId).toBe(getState().tree!.id); + }); + + it('is a no-op when a tree exists but nothing is selected', () => { + getState().addPrimitive('box'); + getState().selectNode(getState().tree!.id); + getState().copySelected(); + getState().selectNode(null); + getState().pasteToSelected(); + expect(getState().tree!.children).toHaveLength(0); + }); + + it('pastes as a child of the selected node with a fresh id', () => { + getState().addPrimitive('box'); + const boxId = getState().tree!.id; + getState().selectNode(boxId); + getState().wrapSelected('union'); + const unionId = getState().tree!.id; + getState().selectNode(boxId); + getState().copySelected(); + + getState().selectNode(unionId); + getState().pasteToSelected(); + + const union = getState().tree!; + expect(union.children).toHaveLength(2); + expect(union.children[1].kind).toBe('box'); + expect(union.children[1].id).not.toBe(boxId); + expect(getState().selectedNodeId).toBe(union.children[1].id); + }); + }); + + // ─── Scenario 19: duplicateSelected with no parent found ────────── + describe('Scenario: duplicateSelected edge cases', () => { + it('is a no-op with no tree or no selection', () => { + getState().duplicateSelected(); + expect(getState().tree).toBeNull(); + }); + }); + + // ─── Scenario 20: moveNode edge cases ────────────────────────────── + describe('Scenario: moveNode guards', () => { + it('is a no-op with no tree', () => { + getState().moveNode('a', 'b'); + expect(getState().tree).toBeNull(); + }); + + it('refuses to move a node into its own descendant', () => { + getState().addPrimitive('box'); + const boxId = getState().tree!.id; + getState().selectNode(boxId); + getState().wrapSelected('shell'); + const shellId = getState().tree!.id; + getState().moveNode(shellId, boxId); + expect(getState().tree!.kind).toBe('shell'); + expect(getState().tree!.children[0].id).toBe(boxId); + }); + + it('is a no-op when the source node cannot be found', () => { + getState().addPrimitive('box'); + getState().moveNode('missing', getState().tree!.id); + expect(getState().tree!.kind).toBe('box'); + }); + }); + + // ─── Scenario 21: simplifyTree ───────────────────────────────────── + describe('Scenario: simplifyTree', () => { + it('removes disabled nodes', () => { + getState().addPrimitive('box'); + getState().addPrimitive('sphere'); + getState().toggleNode(getState().tree!.children[1].id); // disable sphere + getState().simplifyTree(); + expect(getState().tree!.kind).toBe('box'); + }); + + it('collapses identity translate/rotate/scale wrappers', () => { + getState().addPrimitive('box'); + const boxId = getState().tree!.id; + getState().selectNode(boxId); + getState().wrapSelected('translate'); // default translate params are 0,0,0 + getState().simplifyTree(); + expect(getState().tree!.kind).toBe('box'); + }); + + it('collapses single-child booleans', () => { + getState().addPrimitive('box'); + const boxId = getState().tree!.id; + getState().selectNode(boxId); + getState().wrapSelected('union'); + getState().simplifyTree(); + expect(getState().tree!.kind).toBe('box'); + }); + + it('removes empty modifiers and booleans', () => { + const emptyShell = { + id: 'empty-shell', kind: 'shell', label: 'Shell', + params: { thickness: 2 }, children: [] as any[], enabled: true, + }; + useModelerStore.setState({ tree: emptyShell as any }); + getState().simplifyTree(); + expect(getState().tree).toBeNull(); + }); + + it('merges nested transforms of the same kind', () => { + getState().addPrimitive('box'); + const boxId = getState().tree!.id; + getState().selectNode(boxId); + getState().wrapSelected('translate'); + getState().updateNodeParams(getState().tree!.id, { x: 5, y: 0, z: 0 }); + const innerId = getState().tree!.id; + getState().selectNode(innerId); + getState().wrapSelected('translate'); + getState().updateNodeParams(getState().tree!.id, { x: 10, y: 0, z: 0 }); + + getState().simplifyTree(); + const result = getState().tree!; + expect(result.kind).toBe('translate'); + expect(result.params.x).toBe(15); + expect(result.children[0].kind).toBe('box'); + }); + + it('is a no-op with no tree', () => { + getState().simplifyTree(); + expect(getState().tree).toBeNull(); + }); + }); + + // ─── Scenario 22: undo/redo boundaries ───────────────────────────── + describe('Scenario: undo/redo at history boundaries', () => { + it('undo at the start of history is a no-op', () => { + getState().undo(); + expect(getState().tree).toBeNull(); + }); + + it('redo at the end of history is a no-op', () => { + getState().addPrimitive('box'); + getState().redo(); + expect(getState().tree!.kind).toBe('box'); + }); + }); + // ─── Scenario 12: Serialization round-trip ──────────────────────── describe('Scenario: Save and load project', () => { it('round-trips through JSON', () => { diff --git a/src/types/operations.test.ts b/src/types/operations.test.ts index f5157c2..37a09da 100644 --- a/src/types/operations.test.ts +++ b/src/types/operations.test.ts @@ -68,6 +68,30 @@ describe('operations types', () => { expect(nodeSummary(smoothUnion)).toContain('3'); }); + it('nodeSummary covers every remaining node kind', () => { + const mk = (kind: string, params: Record, data?: Record): SDFNodeUI => + ({ id: kind, kind, label: kind, params, children: [], enabled: true, data } as SDFNodeUI); + + expect(nodeSummary(mk('cylinder', { radius: 5, height: 10 }))).toBe('r=5 h=10'); + expect(nodeSummary(mk('torus', { majorRadius: 8, minorRadius: 2 }))).toBe('R=8 r=2'); + expect(nodeSummary(mk('cone', { radius: 4, height: 12 }))).toBe('r=4 h=12'); + expect(nodeSummary(mk('capsule', { radius: 3, height: 9 }))).toBe('r=3 h=9'); + expect(nodeSummary(mk('ellipsoid', { width: 1, height: 2, depth: 3 }))).toBe('1Ɨ2Ɨ3'); + expect(nodeSummary(mk('shell', { thickness: 2 }))).toBe('2mm'); + expect(nodeSummary(mk('offset', { distance: 4 }))).toBe('4mm'); + expect(nodeSummary(mk('round', { radius: 1.5 }))).toBe('r=1.5'); + expect(nodeSummary(mk('translate', { x: 1, y: 2, z: 3 }))).toBe('1, 2, 3'); + expect(nodeSummary(mk('rotate', { x: 90, y: 0, z: 0 }))).toBe('90°, 0°, 0°'); + expect(nodeSummary(mk('scale', { x: 1, y: 1, z: 2 }))).toBe('1, 1, 2'); + expect(nodeSummary(mk('mirror', { mirrorX: true, mirrorY: false, mirrorZ: true }))).toBe('XZ'); + expect(nodeSummary(mk('mirror', { mirrorX: false, mirrorY: false, mirrorZ: false }))).toBe('none'); + expect(nodeSummary(mk('linearPattern', { count: 4, spacing: 10 }))).toBe('4Ɨ @ 10mm'); + expect(nodeSummary(mk('circularPattern', { count: 6 }))).toBe('6Ɨ circular'); + expect(nodeSummary(mk('text', { size: 12 }, { text: 'Hi' }))).toBe('"Hi" 12mm'); + expect(nodeSummary(mk('text', { size: 12 }))).toBe('"Text" 12mm'); + expect(nodeSummary(mk('unknownKind', {}))).toBe(''); + }); + it('incompleteNodeIds finds nodes missing children', () => { const box: SDFNodeUI = { id: 'b', kind: 'box', label: 'Box', params: { width: 10, height: 20, depth: 30 }, children: [], enabled: true }; // A complete union diff --git a/src/worker/sdf/bounds.test.ts b/src/worker/sdf/bounds.test.ts index fa8b079..406fb48 100644 --- a/src/worker/sdf/bounds.test.ts +++ b/src/worker/sdf/bounds.test.ts @@ -70,4 +70,149 @@ describe('computeBounds', () => { expect(bb.max[0]).toBeLessThanOrEqual(5); expect(bb.max[1]).toBeLessThanOrEqual(5); }); + + it('cylinder centered at origin', () => { + const bb = computeBounds({ kind: 'cylinder', radius: 4, height: 10 }); + expect(bb.min).toEqual([-4, -5, -4]); + expect(bb.max).toEqual([4, 5, 4]); + }); + + it('torus bounds use major+minor radius', () => { + const bb = computeBounds({ kind: 'torus', major: 10, minor: 2 }); + expect(bb.min).toEqual([-12, -2, -12]); + expect(bb.max).toEqual([12, 2, 12]); + }); + + it('cone bounds like a cylinder envelope', () => { + const bb = computeBounds({ kind: 'cone', radius: 5, height: 8 }); + expect(bb.min).toEqual([-5, -4, -5]); + expect(bb.max).toEqual([5, 4, 5]); + }); + + it('capsule bounds like a cylinder envelope', () => { + const bb = computeBounds({ kind: 'capsule', radius: 3, height: 6 }); + expect(bb.min).toEqual([-3, -3, -3]); + expect(bb.max).toEqual([3, 3, 3]); + }); + + it('ellipsoid centered at origin', () => { + const bb = computeBounds({ kind: 'ellipsoid', size: [10, 20, 30] }); + expect(bb.min).toEqual([-5, -10, -15]); + expect(bb.max).toEqual([5, 10, 15]); + }); + + it('subtract keeps the base bounds expanded by k', () => { + const a: SDFNode = { kind: 'box', size: [10, 10, 10] }; + const b: SDFNode = { kind: 'sphere', radius: 100 }; + const bb = computeBounds({ kind: 'subtract', a, b, k: 1 }); + expect(bb.min).toEqual([-6, -6, -6]); + expect(bb.max).toEqual([6, 6, 6]); + }); + + it('offset expands by absolute distance', () => { + const bb = computeBounds({ kind: 'offset', child: { kind: 'box', size: [10, 10, 10] }, distance: -3 }); + expect(bb.min).toEqual([-8, -8, -8]); + expect(bb.max).toEqual([8, 8, 8]); + }); + + it('round expands by radius', () => { + const bb = computeBounds({ kind: 'round', child: { kind: 'box', size: [10, 10, 10] }, radius: 2 }); + expect(bb.min).toEqual([-7, -7, -7]); + expect(bb.max).toEqual([7, 7, 7]); + }); + + it('rotated transform rotates all three axes', () => { + const node: SDFNode = { + kind: 'transform', + child: { kind: 'box', size: [10, 10, 10] }, + tx: 0, ty: 0, tz: 0, rx: 90, ry: 90, rz: 90, sx: 1, sy: 1, sz: 1, + }; + const bb = computeBounds(node); + expect(bb.min[0]).toBeCloseTo(-5); + expect(bb.max[0]).toBeCloseTo(5); + }); + + it('linearPattern expands along its axis', () => { + const bb = computeBounds({ + kind: 'linearPattern', + child: { kind: 'box', size: [2, 2, 2] }, + axis: [1, 0, 0], count: 4, spacing: 10, + }); + expect(bb.min[0]).toBeCloseTo(-1); + expect(bb.max[0]).toBeCloseTo(31); + }); + + it('linearPattern falls back to Y axis for a zero-length axis vector', () => { + const bb = computeBounds({ + kind: 'linearPattern', + child: { kind: 'box', size: [2, 2, 2] }, + axis: [0, 0, 0], count: 3, spacing: 5, + }); + expect(bb.min[1]).toBeCloseTo(-1); + expect(bb.max[1]).toBeCloseTo(11); + }); + + it('circularPattern around Y axis rotates in the XZ plane', () => { + const bb = computeBounds({ + kind: 'circularPattern', + child: { kind: 'transform', child: { kind: 'sphere', radius: 1 }, tx: 5, ty: 0, tz: 0, rx: 0, ry: 0, rz: 0, sx: 1, sy: 1, sz: 1 }, + axis: [0, 1, 0], count: 8, + }); + const expectedR = Math.sqrt(6 * 6 + 1 * 1); + expect(bb.max[0]).toBeCloseTo(expectedR); + expect(bb.max[2]).toBeCloseTo(expectedR); + expect(bb.min[1]).toBeCloseTo(-1); + expect(bb.max[1]).toBeCloseTo(1); + }); + + it('circularPattern around X axis rotates in the YZ plane', () => { + const bb = computeBounds({ + kind: 'circularPattern', + child: { kind: 'transform', child: { kind: 'sphere', radius: 1 }, tx: 0, ty: 5, tz: 0, rx: 0, ry: 0, rz: 0, sx: 1, sy: 1, sz: 1 }, + axis: [1, 0, 0], count: 8, + }); + const expectedR = Math.sqrt(6 * 6 + 1 * 1); + expect(bb.max[1]).toBeCloseTo(expectedR); + expect(bb.max[2]).toBeCloseTo(expectedR); + }); + + it('circularPattern around Z axis rotates in the XY plane', () => { + const bb = computeBounds({ + kind: 'circularPattern', + child: { kind: 'transform', child: { kind: 'sphere', radius: 1 }, tx: 5, ty: 0, tz: 0, rx: 0, ry: 0, rz: 0, sx: 1, sy: 1, sz: 1 }, + axis: [0, 0, 1], count: 8, + }); + const expectedR = Math.sqrt(6 * 6 + 1 * 1); + expect(bb.max[0]).toBeCloseTo(expectedR); + expect(bb.max[1]).toBeCloseTo(expectedR); + }); + + it('text with explicit glyph metrics', () => { + const bb = computeBounds({ + kind: 'text', text: 'A', size: 10, depth: 4, font: 'sans', + glyphWidth: 8, glyphAscent: 7, glyphDescent: -1, + }); + expect(bb.min).toEqual([-4, -4, -2]); + expect(bb.max).toEqual([4, 4, 2]); + }); + + it('text falls back to a character-width estimate without glyph metrics', () => { + const bb = computeBounds({ kind: 'text', text: 'AB', size: 10, depth: 4, font: 'sans' }); + expect(bb.min[0]).toBeCloseTo(-6); + expect(bb.max[0]).toBeCloseTo(6); + expect(bb.min[1]).toBeCloseTo(-5); + expect(bb.max[1]).toBeCloseTo(5); + }); + + it('halfSpace returns a large fixed bounding box', () => { + const bb = computeBounds({ kind: 'halfSpace', axis: 'x', position: 0, flip: false }); + expect(bb.min).toEqual([-1000, -1000, -1000]); + expect(bb.max).toEqual([1000, 1000, 1000]); + }); + + it('_far returns a degenerate bounding box', () => { + const bb = computeBounds({ kind: '_far' }); + expect(bb.min).toEqual([0, 0, 0]); + expect(bb.max).toEqual([0, 0, 0]); + }); }); diff --git a/src/worker/sdf/codegen.test.ts b/src/worker/sdf/codegen.test.ts index f606959..56c193f 100644 --- a/src/worker/sdf/codegen.test.ts +++ b/src/worker/sdf/codegen.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { generateGLSL } from './codegen'; +import { generateGLSL, generateSDFFunction } from './codegen'; import type { SDFNode } from './types'; describe('generateGLSL', () => { @@ -69,4 +69,116 @@ describe('generateGLSL', () => { const glsl = generateGLSL(node); expect(glsl).toContain('.y'); }); + + it('handles halfSpace with flip', () => { + const node: SDFNode = { kind: 'halfSpace', axis: 'x', position: 5, flip: true }; + const glsl = generateGLSL(node); + expect(glsl).not.toContain('NaN'); + }); + + it('generates GLSL for every remaining primitive kind', () => { + const kinds: SDFNode[] = [ + { kind: 'cylinder', radius: 5, height: 10 }, + { kind: 'torus', major: 10, minor: 3 }, + { kind: 'cone', radius: 5, height: 10 }, + { kind: 'capsule', radius: 2, height: 10 }, + { kind: 'ellipsoid', size: [10, 20, 30] }, + { kind: 'text', text: 'Hi', size: 10, depth: 4, font: 'sans' }, + { kind: '_far' }, + ]; + for (const node of kinds) { + const glsl = generateGLSL(node); + expect(glsl).toContain('float sdf(vec3 p)'); + expect(glsl).not.toContain('NaN'); + expect(glsl).not.toContain('Infinity'); + } + }); + + it('generates GLSL for subtract and intersect, sharp and smooth', () => { + const a: SDFNode = { kind: 'box', size: [10, 10, 10] }; + const b: SDFNode = { kind: 'sphere', radius: 5 }; + for (const k of [0, 3]) { + const sub = generateGLSL({ kind: 'subtract', a, b, k }); + expect(sub).toContain(k > 0 ? 'mix' : 'max'); + const inter = generateGLSL({ kind: 'intersect', a, b, k }); + expect(inter).toContain(k > 0 ? 'mix' : 'max'); + } + }); + + it('generates GLSL for offset and round', () => { + const child: SDFNode = { kind: 'box', size: [10, 10, 10] }; + expect(generateGLSL({ kind: 'offset', child, distance: 2 })).toContain('float sdf(vec3 p)'); + expect(generateGLSL({ kind: 'round', child, radius: 1 })).toContain('float sdf(vec3 p)'); + }); + + it('generates GLSL for linearPattern using a helper function', () => { + const node: SDFNode = { kind: 'linearPattern', child: { kind: 'sphere', radius: 2 }, axis: [1, 0, 0], count: 4, spacing: 10 }; + const glsl = generateGLSL(node); + expect(glsl).toContain('sdf_helper_0'); + expect(glsl).not.toContain('NaN'); + }); + + it('generates GLSL for linearPattern with a zero-length axis (falls back to Y)', () => { + const node: SDFNode = { kind: 'linearPattern', child: { kind: 'sphere', radius: 2 }, axis: [0, 0, 0], count: 3, spacing: 10 }; + const glsl = generateGLSL(node); + expect(glsl).toContain('sdf_helper_0'); + }); + + it('generates GLSL for circularPattern around each axis', () => { + const child: SDFNode = { kind: 'sphere', radius: 1 }; + const axisY = generateGLSL({ kind: 'circularPattern', child, axis: [0, 1, 0], count: 6 }); + expect(axisY).toContain('.xz'); + const axisX = generateGLSL({ kind: 'circularPattern', child, axis: [1, 0, 0], count: 6 }); + expect(axisX).toContain('.yz'); + const axisZ = generateGLSL({ kind: 'circularPattern', child, axis: [0, 0, 1], count: 6 }); + expect(axisZ).toContain('.xy'); + }); + + it('emits a sdfWarn function when a descendant node is warned', () => { + const node: SDFNode = { + kind: 'union', + a: { kind: 'box', size: [10, 10, 10] }, + b: { kind: 'sphere', radius: 5, warn: true }, + k: 0, + }; + const result = generateSDFFunction(node); + expect(result.hasWarn).toBe(true); + expect(result.glsl).toContain('sdfWarn'); + }); + + it('does not emit sdfWarn when the root itself is warned', () => { + const node: SDFNode = { kind: 'box', size: [10, 10, 10], warn: true }; + const result = generateSDFFunction(node); + expect(result.hasWarn).toBe(false); + expect(result.glsl).not.toContain('sdfWarn'); + }); + + it('does not emit sdfWarn when nothing is warned', () => { + const node: SDFNode = { kind: 'box', size: [10, 10, 10] }; + const result = generateSDFFunction(node); + expect(result.hasWarn).toBe(false); + }); + + it('filters a warned subtree through a modifier chain', () => { + const node: SDFNode = { + kind: 'shell', + child: { + kind: 'union', + a: { kind: 'box', size: [10, 10, 10] }, + b: { kind: 'sphere', radius: 5, warn: true }, + k: 0, + }, + thickness: 2, + }; + const result = generateSDFFunction(node); + expect(result.hasWarn).toBe(true); + expect(result.glsl).toContain('sdfWarn'); + }); + + it('reports a sensible paramCount', () => { + const node: SDFNode = { kind: 'sphere', radius: 5 }; + const result = generateSDFFunction(node); + expect(result.paramCount).toBeGreaterThanOrEqual(1); + expect(result.paramValues).toContain(5); + }); }); diff --git a/src/worker/sdf/evaluate.test.ts b/src/worker/sdf/evaluate.test.ts index 3c79a5e..2e4aed4 100644 --- a/src/worker/sdf/evaluate.test.ts +++ b/src/worker/sdf/evaluate.test.ts @@ -48,6 +48,24 @@ describe('evaluateSDF', () => { const torus: SDFNode = { kind: 'torus', major: 10, minor: 3 }; expect(evaluateSDF(torus, [10, 0, 0])).toBeLessThan(0); }); + + it('cone: inside near the base, outside far away', () => { + const cone: SDFNode = { kind: 'cone', radius: 5, height: 10 }; + expect(evaluateSDF(cone, [0, -4, 0])).toBeLessThan(0); + expect(evaluateSDF(cone, [100, 0, 0])).toBeGreaterThan(0); + }); + + it('capsule: center and surface distances', () => { + const capsule: SDFNode = { kind: 'capsule', radius: 2, height: 10 }; + expect(evaluateSDF(capsule, [0, 0, 0])).toBeCloseTo(-2); + expect(evaluateSDF(capsule, [2, 0, 0])).toBeCloseTo(0); + }); + + it('ellipsoid: center is inside, surface approx zero, and the degenerate center case', () => { + const ell: SDFNode = { kind: 'ellipsoid', size: [10, 20, 30] }; + expect(evaluateSDF(ell, [0, 0, 0])).toBeCloseTo(-5); + expect(Math.abs(evaluateSDF(ell, [5, 0, 0]))).toBeLessThan(0.5); + }); }); describe('booleans', () => { @@ -79,6 +97,14 @@ describe('evaluateSDF', () => { const smoothVal = evaluateSDF(smooth, p); expect(smoothVal).toBeLessThanOrEqual(sharpVal); }); + + it('smooth subtract and intersect evaluate without throwing', () => { + const smoothSub: SDFNode = { kind: 'subtract', a: boxA, b: boxB, k: 2 }; + expect(typeof evaluateSDF(smoothSub, [-3, 0, 0])).toBe('number'); + + const smoothInter: SDFNode = { kind: 'intersect', a: boxA, b: boxB, k: 2 }; + expect(typeof evaluateSDF(smoothInter, [4, 0, 0])).toBe('number'); + }); }); describe('modifiers', () => { @@ -152,5 +178,80 @@ describe('evaluateSDF', () => { const hs: SDFNode = { kind: 'halfSpace', axis: 'y', position: 5, flip: false }; expect(evaluateSDF(hs, [0, 10, 0])).toBeGreaterThan(0); }); + + it('flip inverts the side, and x/z axes work too', () => { + expect(evaluateSDF({ kind: 'halfSpace', axis: 'y', position: 5, flip: true }, [0, 0, 0])).toBeGreaterThan(0); + expect(evaluateSDF({ kind: 'halfSpace', axis: 'x', position: 0, flip: false }, [5, 0, 0])).toBeGreaterThan(0); + expect(evaluateSDF({ kind: 'halfSpace', axis: 'z', position: 0, flip: false }, [0, 0, 5])).toBeGreaterThan(0); + }); + }); + + describe('patterns', () => { + it('linearPattern repeats the child along its axis', () => { + const sphere: SDFNode = { kind: 'sphere', radius: 2 }; + const pattern: SDFNode = { kind: 'linearPattern', child: sphere, axis: [1, 0, 0], count: 3, spacing: 10 }; + expect(evaluateSDF(pattern, [0, 0, 0])).toBeCloseTo(-2); + expect(evaluateSDF(pattern, [10, 0, 0])).toBeCloseTo(-2); + expect(evaluateSDF(pattern, [20, 0, 0])).toBeCloseTo(-2); + }); + + it('linearPattern with a zero-length axis falls back to the plain child', () => { + const sphere: SDFNode = { kind: 'sphere', radius: 2 }; + const pattern: SDFNode = { kind: 'linearPattern', child: sphere, axis: [0, 0, 0], count: 3, spacing: 10 }; + expect(evaluateSDF(pattern, [0, 0, 0])).toBeCloseTo(-2); + }); + + it('circularPattern repeats around the Y, X, and Z axes', () => { + const sphereAtX: SDFNode = { kind: 'transform', child: { kind: 'sphere', radius: 1 }, tx: 5, ty: 0, tz: 0, rx: 0, ry: 0, rz: 0, sx: 1, sy: 1, sz: 1 }; + const patternY: SDFNode = { kind: 'circularPattern', child: sphereAtX, axis: [0, 1, 0], count: 4 }; + expect(evaluateSDF(patternY, [5, 0, 0])).toBeCloseTo(-1); + expect(evaluateSDF(patternY, [0, 0, 5])).toBeCloseTo(-1); + + const sphereAtY: SDFNode = { kind: 'transform', child: { kind: 'sphere', radius: 1 }, tx: 0, ty: 5, tz: 0, rx: 0, ry: 0, rz: 0, sx: 1, sy: 1, sz: 1 }; + const patternX: SDFNode = { kind: 'circularPattern', child: sphereAtY, axis: [1, 0, 0], count: 4 }; + expect(evaluateSDF(patternX, [0, 5, 0])).toBeCloseTo(-1); + + const patternZ: SDFNode = { kind: 'circularPattern', child: sphereAtX, axis: [0, 0, 1], count: 4 }; + expect(evaluateSDF(patternZ, [5, 0, 0])).toBeCloseTo(-1); + }); + }); + + describe('text', () => { + it('falls back to a box shape without glyph data', () => { + const text: SDFNode = { kind: 'text', text: 'Hi', size: 10, depth: 4, font: 'sans' }; + expect(evaluateSDF(text, [0, 0, 0])).toBeLessThan(0); + expect(evaluateSDF(text, [1000, 0, 0])).toBeGreaterThan(0); + }); + + it('evaluates a glyph contour built from line segments, extruded in Z', () => { + const text: SDFNode = { + kind: 'text', text: 'I', size: 10, depth: 4, font: 'sans', + glyphWidth: 4, glyphAscent: 10, glyphDescent: 0, + glyphSegments: [ + { type: 'L', x0: 0, y0: 0, x1: 4, y1: 0 }, + { type: 'L', x0: 4, y0: 0, x1: 4, y1: 10 }, + { type: 'L', x0: 4, y0: 10, x1: 0, y1: 10 }, + { type: 'L', x0: 0, y0: 10, x1: 0, y1: 0 }, + ], + }; + expect(evaluateSDF(text, [0, 0, 0])).toBeLessThan(0); + expect(evaluateSDF(text, [1000, 0, 0])).toBeGreaterThan(0); + }); + + it('evaluates a glyph contour built from quadratic beziers', () => { + const text: SDFNode = { + kind: 'text', text: 'O', size: 10, depth: 4, font: 'sans', + glyphWidth: 6, glyphAscent: 10, glyphDescent: 0, + glyphBeziers: [ + { type: 'Q', x0: 0, y0: 5, x1: 0, y1: 10, x2: 6, y2: 10 }, + { type: 'Q', x0: 6, y0: 10, x1: 6, y1: 5, x2: 0, y2: 5 }, + ], + }; + expect(typeof evaluateSDF(text, [3, 5, 0])).toBe('number'); + }); + }); + + it('_far is always effectively infinite', () => { + expect(evaluateSDF({ kind: '_far' }, [0, 0, 0])).toBe(1e10); }); }); diff --git a/src/worker/sdf/simplify.test.ts b/src/worker/sdf/simplify.test.ts index 61b1560..bb30b77 100644 --- a/src/worker/sdf/simplify.test.ts +++ b/src/worker/sdf/simplify.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { simplifyMesh } from './simplify'; +import { simplifyMesh, splitCreaseEdges } from './simplify'; import { marchingCubes } from './marchingCubes'; import { evaluateSDF } from './evaluate'; import type { SDFNode, BBox } from './types'; +import type { MeshResult } from './marchingCubes'; function makeGrid(node: SDFNode, resolution: number, bbox: BBox): Float32Array { const res = resolution; @@ -132,3 +133,66 @@ describe('simplifyMesh', () => { }); }); +describe('splitCreaseEdges', () => { + it('splits vertices across a sharp 90-degree fold', () => { + // Two triangles sharing edge (1,2), folded at a right angle. + const mesh: MeshResult = { + positions: new Float32Array([ + 0, 0, 0, // 0 + 1, 0, 0, // 1 + 0, 1, 0, // 2 + 0, 1, 1, // 3 + ]), + normals: new Float32Array(12), + indices: new Uint32Array([0, 1, 2, 1, 2, 3]), + }; + const result = splitCreaseEdges(mesh); + + // The shared edge is a sharp crease, so vertices 1 and 2 must be duplicated + // (once per face) instead of shared with an averaged normal. + expect(result.positions.length / 3).toBeGreaterThan(4); + expect(result.indices.length).toBe(6); + // Every index must reference a valid vertex + const numVerts = result.positions.length / 3; + for (const idx of result.indices) { + expect(idx).toBeGreaterThanOrEqual(0); + expect(idx).toBeLessThan(numVerts); + } + // Normals should be unit length + for (let i = 0; i < result.normals.length; i += 3) { + const len = Math.sqrt(result.normals[i] ** 2 + result.normals[i + 1] ** 2 + result.normals[i + 2] ** 2); + expect(len).toBeCloseTo(1, 5); + } + }); + + it('keeps shared vertices with averaged normals on a smooth closed mesh', () => { + // A finely-tessellated sphere has no boundary edges and small angles + // between adjacent faces, so no creases should be detected. + const bbox: BBox = { min: [-8, -8, -8], max: [8, 8, 8] }; + const res = 32; + const grid = new Float32Array(res * res * res); + const dx = (bbox.max[0] - bbox.min[0]) / res; + for (let z = 0; z < res; z++) { + for (let y = 0; y < res; y++) { + for (let x = 0; x < res; x++) { + const px = bbox.min[0] + (x + 0.5) * dx; + const py = bbox.min[1] + (y + 0.5) * dx; + const pz = bbox.min[2] + (z + 0.5) * dx; + grid[z * res * res + y * res + x] = evaluateSDF({ kind: 'sphere', radius: 5 }, [px, py, pz]); + } + } + } + const sdfNode: SDFNode = { kind: 'sphere', radius: 5 }; + const mesh = marchingCubes(grid, res, bbox, sdfNode); + + const result = splitCreaseEdges(mesh); + // A smooth closed mesh keeps the same vertex/index count, only normals change. + expect(result.positions.length).toBe(mesh.positions.length); + expect(result.indices.length).toBe(mesh.indices.length); + for (let i = 0; i < result.normals.length; i += 3) { + const len = Math.sqrt(result.normals[i] ** 2 + result.normals[i + 1] ** 2 + result.normals[i + 2] ** 2); + expect(len).toBeCloseTo(1, 1); + } + }); +}); +