Compare commits
38
Commits
034e397864
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c55298ab7 | ||
|
|
ba82df7694 | ||
|
|
b1607a4b00 | ||
|
|
05dc554d79 | ||
|
|
58b14a5b64 | ||
|
|
8db70fdd72 | ||
|
|
d298108516 | ||
|
|
267d9ab8eb | ||
|
|
2df5ef2089 | ||
|
|
3018c3f1b5 | ||
|
|
008f1254f8 | ||
|
|
4432e38cd2 | ||
|
|
26601bef46 | ||
|
|
0013dc175a | ||
|
|
24e7b42896 | ||
|
|
108cea0491 | ||
|
|
2224774e00 | ||
|
|
07e0fc1192 | ||
|
|
833e1ec8dc | ||
|
|
4b19245c6c | ||
|
|
0857ce482b | ||
|
|
1cff9e6eb2 | ||
|
|
82228e9141 | ||
|
|
910822028b | ||
|
|
e13db8c1de | ||
|
|
ee9caf71cb | ||
|
|
54b93055ba | ||
|
|
57f861510e | ||
|
|
52375e5ab1 | ||
|
|
dc23023b32 | ||
|
|
718bc28bd0 | ||
|
|
ce7db0f376 | ||
|
|
331d653ae6 | ||
|
|
8681508cb6 | ||
|
|
106d39453d | ||
|
|
07a9b8bc11 | ||
|
|
93e9c59436 | ||
|
|
449e2d430a |
No files matched your search
@@ -66,6 +66,9 @@ jobs:
|
|||||||
- name: Build Production
|
- name: Build Production
|
||||||
run: npx ng build --configuration production
|
run: npx ng build --configuration production
|
||||||
|
|
||||||
|
- name: Prerender Check
|
||||||
|
run: npm run prerender:check
|
||||||
|
|
||||||
# 5. Lighthouse Audit (Performance & SEO)
|
# 5. Lighthouse Audit (Performance & SEO)
|
||||||
- name: Lighthouse CI
|
- name: Lighthouse CI
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ multi-language support (EN/DE) via **ngx-translate**.
|
|||||||
|
|
||||||
The app is built as a static **Single Page Application (SPA)** served by **NGINX**,
|
The app is built as a static **Single Page Application (SPA)** served by **NGINX**,
|
||||||
deployed at:
|
deployed at:
|
||||||
👉 [https://app.andreas-dahm.eu](https://app.andreas-dahm.eu)
|
👉 [https://andreas-dahm.eu](https://andreas-dahm.eu)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -138,12 +138,12 @@ docker compose pull
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
✅ Live site at https://app.andreas-dahm.eu
|
✅ Live site at https://andreas-dahm.eu
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🪄 Maintainer
|
## 🪄 Maintainer
|
||||||
|
|
||||||
Andreas Dahm
|
Andreas Dahm
|
||||||
📧 andreas.dahm@gmail.com
|
📧 info@andreas-dahm.eu
|
||||||
🌐 https://andreas-dahm.eu
|
🌐 https://andreas-dahm.eu
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
Open ideas for the playground frontend. Tick a box when done.
|
||||||
|
|
||||||
|
## Quick wins
|
||||||
|
|
||||||
|
- [ ] **Labyrinth grid size crash**: `labyrinth.component.html` still binds `[gridRows]`; typed sizes above the max crash the page. Same fix as pathfinding (clamp in code, drop the binding).
|
||||||
|
- [ ] **Fourier circles slider thumb**: at "Circles: 256" the thumb sits near the left end, like at 30. Check `max`/value binding of the circles `mat-slider` (seen 2026-10-14, not verified).
|
||||||
|
- [ ] **Lighthouse gate**: add an `assert` block to `lighthouserc.json` (e.g. performance, accessibility, SEO >= 0.9). Today CI runs the audit but never fails.
|
||||||
|
Note: the `@lhci/cli` → lighthouse 13 override in `package.json` is only safe without `assert`. Check it again before you add one.
|
||||||
|
- [ ] **robots.txt + sitemap.xml**: static files in `public/`, listing all routes from `app.routes.ts`.
|
||||||
|
- [ ] **SEO per algorithm page**: all algorithm routes use `data: { seo: 'ALGORITHMS' }`. Give each one its own key and add `SEO.<KEY>.TITLE` / `DESCRIPTION` to `en.json` and `de.json`.
|
||||||
|
- [ ] **nginx security headers**: no CSP, `X-Content-Type-Options`, `Referrer-Policy`, `frame-ancestors` in `nginx.conf` (gzip done). First check with `curl -I https://andreas-dahm.eu/` if the reverse proxy already sets them.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- [ ] `seo.service.ts`: spec for title/meta update on route change and language change.
|
||||||
|
- [ ] Pure logic without specs: `pathfinding-animator.ts`, cloth and pendulum GPU strategies (the math around them, not the shaders), `fractal3d`.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- [ ] **Cloth ICM drop scene**: hits mostly solved, cloth settling and CCD open. With ICM on, drop scene 35x35 (spacing 0.145, same 4.9 m cloth), gap 0.035, one mid-frame proximity re-detection: ~0 intersections after landing (avg 1.5/frame and 82 % of frames at 0 after frame 150; 0 at the marks 100-450 except 11 at 600), 9-10 ms/frame headless; was 263 at frame 900 on 50x50. ICM off: avg 148 (max 356) after frame 150 (125 at frames 100-240), 68 avg over 1800 frames, bounded but reaches the 3 m/s speed cap at times. Finer grids or a gap >= 0.04 at this size make ICM off blow up (thousands of hits, 60-100 ms frames). Cloth still does not settle (fold flutter beside the sphere, max speed 0.6-1.0 m/s with ICM on, also with collisions off). Next: Bridson 2002 CCD + velocity repulsion, then damping of the fold flutter.
|
||||||
|
- [ ] **Shareable demo state**: keep demo settings in query params (fractal zoom/center, sorting algorithm, seeds). Opening the link restores the view.
|
||||||
|
- [ ] **New algorithm demos** (pattern: route + `RouterConstants` + `AlgorithmsService` entry + i18n + information panel):
|
||||||
|
- [x] Boids / flocking (GPU path like cloth)
|
||||||
|
- [x] **Physarum** (slime mold transport networks, trail map on GPU compute, CPU fallback)
|
||||||
|
- [ ] Boids 3D (later, 2D done)
|
||||||
|
- [x] **Voronoi / Delaunay** (Bowyer-Watson, CPU + Canvas 2D)
|
||||||
|
- [x] A* vs Dijkstra side by side (reuse pathfinding)
|
||||||
|
- [x] Fourier epicycles
|
||||||
|
- [x] Reaction-diffusion (Gray-Scott) shader (reuse the fractal shader setup)
|
||||||
|
- [x] **Lenia** (continuous Game of Life, shader ping-pong like Gray-Scott)
|
||||||
|
- [x] **Fluid (Stable Fluids)** (velocity, pressure and dye shader ping-pong like Lenia)
|
||||||
|
- [x] **Cloth self-collision (ICM)** (Volino and Magnenat-Thalmann 2006, CPU: BVH with normal-cone culling, contour tracing, intersection contour minimization)
|
||||||
|
- [x] **Ant colony** (ACO on TSP, CPU + Canvas 2D)
|
||||||
@@ -41,6 +41,10 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
"node_modules/@fontsource/roboto/300.css",
|
||||||
|
"node_modules/@fontsource/roboto/400.css",
|
||||||
|
"node_modules/@fontsource/roboto/500.css",
|
||||||
|
"node_modules/material-icons/iconfont/filled.css",
|
||||||
"src/tailwind.css",
|
"src/tailwind.css",
|
||||||
"src/styles.scss",
|
"src/styles.scss",
|
||||||
"node_modules/swiper/swiper-bundle.css"
|
"node_modules/swiper/swiper-bundle.css"
|
||||||
@@ -60,7 +64,17 @@
|
|||||||
"maximumError": "8kB"
|
"maximumError": "8kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"optimization": {
|
||||||
|
"scripts": true,
|
||||||
|
"styles": {
|
||||||
|
"minify": true,
|
||||||
|
"inlineCritical": false
|
||||||
|
},
|
||||||
|
"fonts": true
|
||||||
|
},
|
||||||
"outputHashing": "all",
|
"outputHashing": "all",
|
||||||
|
"outputMode": "static",
|
||||||
|
"server": "src/main.server.ts",
|
||||||
"serviceWorker": "ngsw-config.json"
|
"serviceWorker": "ngsw-config.json"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"ci": {
|
"ci": {
|
||||||
"collect": {
|
"collect": {
|
||||||
"numberOfRuns": 1,
|
"numberOfRuns": 1,
|
||||||
|
"autodiscoverUrlBlocklist": ["/index.csr.html"],
|
||||||
"staticDistDir": "./dist/playground-frontend/browser",
|
"staticDistDir": "./dist/playground-frontend/browser",
|
||||||
"settings": {
|
"settings": {
|
||||||
"chromeFlags": "--no-sandbox --headless --disable-gpu --disable-dev-shm-usage"
|
"chromeFlags": "--no-sandbox --headless --disable-gpu --disable-dev-shm-usage"
|
||||||
|
|||||||
+22
-4
@@ -3,6 +3,14 @@ server {
|
|||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
# No access log: no visitor IPs stored (privacy page says so)
|
||||||
|
access_log off;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/css application/javascript application/json application/manifest+json image/svg+xml text/plain;
|
||||||
|
|
||||||
# index.html: never cache
|
# index.html: never cache
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
|
||||||
@@ -41,6 +49,12 @@ server {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Self-hosted fonts, content-hashed by the build
|
||||||
|
location ^~ /media/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
# Assets (Bilder, JSON, Fonts, etc.): just revalidate
|
# Assets (Bilder, JSON, Fonts, etc.): just revalidate
|
||||||
location ^~ /assets/ {
|
location ^~ /assets/ {
|
||||||
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
|
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
|
||||||
@@ -49,13 +63,17 @@ server {
|
|||||||
|
|
||||||
# Also no cache for other html site
|
# Also no cache for other html site
|
||||||
location ~* \.html$ {
|
location ~* \.html$ {
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
|
||||||
add_header Pragma "no-cache";
|
add_header Pragma "no-cache" always;
|
||||||
add_header Expires "0";
|
add_header Expires "0" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
|
||||||
|
# Prerendered pages live at <route>/index.html. Unknown URLs get the CSR shell
|
||||||
|
# with status 404 (error_page without "=" keeps the status).
|
||||||
|
try_files $uri $uri/index.html =404;
|
||||||
|
error_page 404 /index.csr.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||||
"index": "/index.html",
|
"index": "/index.csr.html",
|
||||||
"assetGroups": [
|
"assetGroups": [
|
||||||
{
|
{
|
||||||
"name": "app",
|
"name": "app",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"/favicon.ico",
|
"/favicon.ico",
|
||||||
"/index.html",
|
"/index.html",
|
||||||
|
"/index.csr.html",
|
||||||
"/manifest.webmanifest",
|
"/manifest.webmanifest",
|
||||||
"/*.css",
|
"/*.css",
|
||||||
"/*.js"
|
"/*.js"
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"resources": {
|
"resources": {
|
||||||
"files": [
|
"files": [
|
||||||
"/assets/**",
|
"/assets/**",
|
||||||
|
"/media/**",
|
||||||
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+69
-16
@@ -17,11 +17,15 @@
|
|||||||
"@angular/forms": "~22.2.2",
|
"@angular/forms": "~22.2.2",
|
||||||
"@angular/material": "~22.2.2",
|
"@angular/material": "~22.2.2",
|
||||||
"@angular/platform-browser": "~22.2.2",
|
"@angular/platform-browser": "~22.2.2",
|
||||||
|
"@angular/platform-server": "~22.2.2",
|
||||||
"@angular/router": "~22.2.2",
|
"@angular/router": "~22.2.2",
|
||||||
"@angular/service-worker": "~22.2.2",
|
"@angular/service-worker": "~22.2.2",
|
||||||
|
"@angular/ssr": "~22.2.2",
|
||||||
"@babylonjs/core": "^9.30.0",
|
"@babylonjs/core": "^9.30.0",
|
||||||
|
"@fontsource/roboto": "^5.3.0",
|
||||||
"@ngx-translate/core": "^18.0.0",
|
"@ngx-translate/core": "^18.0.0",
|
||||||
"@ngx-translate/http-loader": "^18.0.0",
|
"@ngx-translate/http-loader": "^18.0.0",
|
||||||
|
"material-icons": "^1.13.14",
|
||||||
"rxjs": "~7.8.2",
|
"rxjs": "~7.8.2",
|
||||||
"swiper": "^14.3.0",
|
"swiper": "^14.3.0",
|
||||||
"tslib": "~2.8.1"
|
"tslib": "~2.8.1"
|
||||||
@@ -1117,6 +1121,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular/platform-server": {
|
||||||
|
"version": "22.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-22.2.2.tgz",
|
||||||
|
"integrity": "sha512-CBqkqtkpIkEAtKJBRSplgBlWzNe2CMsbIZ4FFOPh9uHzBTmztA/D/QXqZKgnHLSP79FUmGjzrBizpk8fo9oVSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"xhr2": "^0.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": "22.2.2",
|
||||||
|
"@angular/compiler": "22.2.2",
|
||||||
|
"@angular/core": "22.2.2",
|
||||||
|
"@angular/platform-browser": "22.2.2",
|
||||||
|
"rxjs": "^6.5.3 || ^7.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@angular/router": {
|
"node_modules/@angular/router": {
|
||||||
"version": "22.2.2",
|
"version": "22.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.2.2.tgz",
|
||||||
@@ -1154,6 +1178,27 @@
|
|||||||
"rxjs": "^6.5.3 || ^7.4.0"
|
"rxjs": "^6.5.3 || ^7.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular/ssr": {
|
||||||
|
"version": "22.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-22.2.2.tgz",
|
||||||
|
"integrity": "sha512-iMcwT5ZynIj33R0XSq+AtC5Ra6FGKOvV3qU0sjRr+lUaGfL6MjygPovPFsKLi8HzuGOA5dYDKffJfPedsC9Rxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"beasties": "0.5.4",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": "^22.0.0",
|
||||||
|
"@angular/core": "^22.0.0",
|
||||||
|
"@angular/platform-server": "^22.0.0",
|
||||||
|
"@angular/router": "^22.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@angular/platform-server": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@apm-js-collab/code-transformer": {
|
"node_modules/@apm-js-collab/code-transformer": {
|
||||||
"version": "0.18.1",
|
"version": "0.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.18.1.tgz",
|
||||||
@@ -2062,6 +2107,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/roboto": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-BapRJOWYP+LZ21zp+wBQjfpPYKRoxc4LspJ/RLuI+HSMBD5u/X4O+ESDrSvEqDSy0rAl7GwBJ+09mdc16cVQ1Q==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@formatjs/ecma402-abstract": {
|
"node_modules/@formatjs/ecma402-abstract": {
|
||||||
"version": "2.3.6",
|
"version": "2.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
|
||||||
@@ -5888,7 +5942,6 @@
|
|||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/beasties/-/beasties-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/beasties/-/beasties-0.5.4.tgz",
|
||||||
"integrity": "sha512-8/lOOjT+PElYCnaUbmhgd3Uad5R1VTQ4Wg0k4rK8bX04m5WEqHNmnlLy3Q/lJ7dk25XA+72gayzc/skTfKKpcA==",
|
"integrity": "sha512-8/lOOjT+PElYCnaUbmhgd3Uad5R1VTQ4Wg0k4rK8bX04m5WEqHNmnlLy3Q/lJ7dk25XA+72gayzc/skTfKKpcA==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"css-select": "^7.0.0",
|
"css-select": "^7.0.0",
|
||||||
@@ -5990,7 +6043,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
|
||||||
"integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
|
"integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
@@ -6607,7 +6659,6 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
|
||||||
"integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
|
"integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"boolbase": "^2.0.0",
|
"boolbase": "^2.0.0",
|
||||||
@@ -6642,7 +6693,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
|
||||||
"integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
|
"integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
@@ -6823,7 +6873,6 @@
|
|||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
|
||||||
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
|
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"domelementtype": "^3.0.0",
|
"domelementtype": "^3.0.0",
|
||||||
@@ -6842,7 +6891,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
|
||||||
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
|
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -6858,7 +6906,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
|
||||||
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
|
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"domelementtype": "^3.0.0"
|
"domelementtype": "^3.0.0"
|
||||||
@@ -6875,7 +6922,6 @@
|
|||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
|
||||||
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
|
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dom-serializer": "^3.0.0",
|
"dom-serializer": "^3.0.0",
|
||||||
@@ -8045,7 +8091,6 @@
|
|||||||
"version": "12.0.0",
|
"version": "12.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
|
||||||
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
|
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
{
|
{
|
||||||
@@ -9543,6 +9588,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/material-icons": {
|
||||||
|
"version": "1.13.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/material-icons/-/material-icons-1.13.14.tgz",
|
||||||
|
"integrity": "sha512-kZOfc7xCC0rAT8Q3DQixYAeT+tBqZnxkseQtp2bxBxz7q5pMAC+wmit7vJn1g/l7wRU+HEPq23gER4iPjGs5Cg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -9784,7 +9835,6 @@
|
|||||||
"version": "3.3.20",
|
"version": "3.3.20",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -9900,7 +9950,6 @@
|
|||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
|
||||||
"integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
|
"integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"boolbase": "^2.0.0"
|
"boolbase": "^2.0.0"
|
||||||
@@ -10330,7 +10379,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
@@ -10363,7 +10411,6 @@
|
|||||||
"version": "8.5.29",
|
"version": "8.5.29",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
||||||
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -10392,14 +10439,12 @@
|
|||||||
"version": "0.2.3",
|
"version": "0.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
|
||||||
"integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==",
|
"integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/postcss-safe-parser": {
|
"node_modules/postcss-safe-parser": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz",
|
||||||
"integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==",
|
"integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -11468,7 +11513,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -12793,6 +12837,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xhr2": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
|||||||
+6
-1
@@ -9,7 +9,8 @@
|
|||||||
"test": "ng test --watch=false",
|
"test": "ng test --watch=false",
|
||||||
"lint": "ng lint && npm run assets:check",
|
"lint": "ng lint && npm run assets:check",
|
||||||
"i18n:check": "node scripts/check-i18n.mjs",
|
"i18n:check": "node scripts/check-i18n.mjs",
|
||||||
"assets:check": "node scripts/check-assets.mjs"
|
"assets:check": "node scripts/check-assets.mjs",
|
||||||
|
"prerender:check": "node scripts/check-prerender.mjs"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -22,11 +23,15 @@
|
|||||||
"@angular/forms": "~22.2.2",
|
"@angular/forms": "~22.2.2",
|
||||||
"@angular/material": "~22.2.2",
|
"@angular/material": "~22.2.2",
|
||||||
"@angular/platform-browser": "~22.2.2",
|
"@angular/platform-browser": "~22.2.2",
|
||||||
|
"@angular/platform-server": "~22.2.2",
|
||||||
"@angular/router": "~22.2.2",
|
"@angular/router": "~22.2.2",
|
||||||
"@angular/service-worker": "~22.2.2",
|
"@angular/service-worker": "~22.2.2",
|
||||||
|
"@angular/ssr": "~22.2.2",
|
||||||
"@babylonjs/core": "^9.30.0",
|
"@babylonjs/core": "^9.30.0",
|
||||||
|
"@fontsource/roboto": "^5.3.0",
|
||||||
"@ngx-translate/core": "^18.0.0",
|
"@ngx-translate/core": "^18.0.0",
|
||||||
"@ngx-translate/http-loader": "^18.0.0",
|
"@ngx-translate/http-loader": "^18.0.0",
|
||||||
|
"material-icons": "^1.13.14",
|
||||||
"rxjs": "~7.8.2",
|
"rxjs": "~7.8.2",
|
||||||
"swiper": "^14.3.0",
|
"swiper": "^14.3.0",
|
||||||
"tslib": "~2.8.1"
|
"tslib": "~2.8.1"
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Verifies the production build: every route has prerendered HTML with its own
|
||||||
|
// canonical and og:url, the CSR shell exists, and no Google font host is left.
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const distDir = join(root, 'dist', 'playground-frontend', 'browser');
|
||||||
|
const ORIGIN = 'https://andreas-dahm.eu';
|
||||||
|
const FORBIDDEN_HOSTS = ['fonts.googleapis.com', 'fonts.gstatic.com'];
|
||||||
|
|
||||||
|
function* walk(dir) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
yield* walk(path);
|
||||||
|
} else {
|
||||||
|
yield path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function routePaths() {
|
||||||
|
const constants = readFileSync(join(root, 'src', 'app', 'constants', 'RouterConstants.ts'), 'utf8');
|
||||||
|
const paths = [...constants.matchAll(/PATH: '([^']+)'/g)].map(match => match[1]);
|
||||||
|
return ['', ...paths];
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRoute(route, failures) {
|
||||||
|
const file = join(distDir, route, 'index.html');
|
||||||
|
if (!existsSync(file)) {
|
||||||
|
failures.push(`missing ${file}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = readFileSync(file, 'utf8');
|
||||||
|
const url = `${ORIGIN}/${route}`;
|
||||||
|
const label = route || '/';
|
||||||
|
if (!html.includes(`rel="canonical" href="${url}"`) && !html.includes(`href="${url}" rel="canonical"`)) {
|
||||||
|
failures.push(`${label}: canonical is not ${url}`);
|
||||||
|
}
|
||||||
|
if (!html.includes(`property="og:url" content="${url}"`) && !html.includes(`content="${url}" property="og:url"`)) {
|
||||||
|
failures.push(`${label}: og:url is not ${url}`);
|
||||||
|
}
|
||||||
|
if (!/<title>[^<]+<\/title>/.test(html)) {
|
||||||
|
failures.push(`${label}: empty <title>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkForbiddenHosts(failures) {
|
||||||
|
for (const file of walk(distDir)) {
|
||||||
|
const content = readFileSync(file, 'latin1');
|
||||||
|
for (const host of FORBIDDEN_HOSTS) {
|
||||||
|
if (content.includes(host)) {
|
||||||
|
failures.push(`${file} references ${host}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(distDir)) {
|
||||||
|
console.error(`Build output not found: ${distDir}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
routePaths().forEach(route => checkRoute(route, failures));
|
||||||
|
if (!existsSync(join(distDir, 'index.csr.html'))) {
|
||||||
|
failures.push('missing index.csr.html');
|
||||||
|
}
|
||||||
|
checkForbiddenHosts(failures);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(`Prerender check failed:\n${failures.join('\n')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('Prerender check passed.');
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { mergeApplicationConfig } from '@angular/core';
|
||||||
|
import { provideServerRendering, withRoutes } from '@angular/ssr';
|
||||||
|
import { appConfig } from './app.config';
|
||||||
|
import { serverRoutes } from './app.routes.server';
|
||||||
|
|
||||||
|
export const serverConfig = mergeApplicationConfig(appConfig, {
|
||||||
|
providers: [provideServerRendering(withRoutes(serverRoutes))],
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
|||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import {provideAnimations} from '@angular/platform-browser/animations';
|
import {provideAnimations} from '@angular/platform-browser/animations';
|
||||||
import {provideHttpClient, withXhr} from '@angular/common/http';
|
import {provideHttpClient, withFetch} from '@angular/common/http';
|
||||||
import {provideServiceWorker} from '@angular/service-worker';
|
import {provideServiceWorker} from '@angular/service-worker';
|
||||||
import {provideTranslateService} from '@ngx-translate/core';
|
import {provideTranslateService} from '@ngx-translate/core';
|
||||||
import {provideTranslateHttpLoader} from '@ngx-translate/http-loader';
|
import {provideTranslateHttpLoader} from '@ngx-translate/http-loader';
|
||||||
@@ -25,7 +25,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
anchorScrolling: 'enabled'
|
anchorScrolling: 'enabled'
|
||||||
})),
|
})),
|
||||||
provideAnimations(),
|
provideAnimations(),
|
||||||
provideHttpClient(withXhr()),
|
provideHttpClient(withFetch()),
|
||||||
provideTranslateService({
|
provideTranslateService({
|
||||||
lang: INITIAL_LANG,
|
lang: INITIAL_LANG,
|
||||||
fallbackLang: 'en',
|
fallbackLang: 'en',
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { RenderMode } from '@angular/ssr';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { serverRoutes } from './app.routes.server';
|
||||||
|
|
||||||
|
describe('serverRoutes', () => {
|
||||||
|
it('prerenders every static route', () => {
|
||||||
|
const staticPaths = routes.map(route => route.path).filter(path => path !== '**');
|
||||||
|
|
||||||
|
const prerendered = serverRoutes
|
||||||
|
.filter(route => route.renderMode === RenderMode.Prerender)
|
||||||
|
.map(route => route.path);
|
||||||
|
|
||||||
|
expect(prerendered).toEqual(staticPaths);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the catch-all on the client', () => {
|
||||||
|
const catchAll = serverRoutes.find(route => route.path === '**');
|
||||||
|
|
||||||
|
expect(catchAll?.renderMode).toBe(RenderMode.Client);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
const WILDCARD = '**';
|
||||||
|
|
||||||
|
/** Every static route gets its own HTML at build time; the catch-all stays client-rendered (it cannot be prerendered without params). */
|
||||||
|
export const serverRoutes: ServerRoute[] = [
|
||||||
|
...routes
|
||||||
|
.filter(route => route.path !== WILDCARD)
|
||||||
|
.map((route): ServerRoute => ({ path: route.path ?? '', renderMode: RenderMode.Prerender })),
|
||||||
|
{ path: WILDCARD, renderMode: RenderMode.Client },
|
||||||
|
];
|
||||||
@@ -9,13 +9,23 @@ export const routes: Routes = [
|
|||||||
{ path: RouterConstants.PATHFINDING.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/pathfinding.component').then(m => m.PathfindingComponent), data: { seo: 'ALGORITHMS' } },
|
{ path: RouterConstants.PATHFINDING.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/pathfinding.component').then(m => m.PathfindingComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.SORTING.PATH, loadComponent: () => import('./pages/algorithms/sorting/sorting.component').then(m => m.SortingComponent), data: { seo: 'ALGORITHMS' } },
|
{ path: RouterConstants.SORTING.PATH, loadComponent: () => import('./pages/algorithms/sorting/sorting.component').then(m => m.SortingComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.IMPRINT.PATH, loadComponent: () => import('./pages/imprint/imprint.component').then(m => m.ImprintComponent), data: { seo: 'IMPRINT' } },
|
{ path: RouterConstants.IMPRINT.PATH, loadComponent: () => import('./pages/imprint/imprint.component').then(m => m.ImprintComponent), data: { seo: 'IMPRINT' } },
|
||||||
|
{ path: RouterConstants.PRIVACY.PATH, loadComponent: () => import('./pages/privacy/privacy.component').then(m => m.PrivacyComponent), data: { seo: 'PRIVACY' } },
|
||||||
{ path: RouterConstants.GOL.PATH, loadComponent: () => import('./pages/algorithms/conway-gol/conway-gol.component').then(m => m.ConwayGolComponent), data: { seo: 'ALGORITHMS' } },
|
{ path: RouterConstants.GOL.PATH, loadComponent: () => import('./pages/algorithms/conway-gol/conway-gol.component').then(m => m.ConwayGolComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.LABYRINTH.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/labyrinth/labyrinth.component').then(m => m.LabyrinthComponent), data: { seo: 'ALGORITHMS' } },
|
{ path: RouterConstants.LABYRINTH.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/labyrinth/labyrinth.component').then(m => m.LabyrinthComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.FRACTAL.PATH, loadComponent: () => import('./pages/algorithms/fractal/fractal.component').then(m => m.FractalComponent), data: { seo: 'ALGORITHMS' } },
|
{ path: RouterConstants.FRACTAL.PATH, loadComponent: () => import('./pages/algorithms/fractal/fractal.component').then(m => m.FractalComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent), data: { seo: 'ALGORITHMS' } },
|
{ path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default), data: { seo: 'ALGORITHMS' } },
|
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent), data: { seo: 'ALGORITHMS' } },
|
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.CLOTH_ICM.PATH, loadComponent: () => import('./pages/algorithms/cloth-icm/cloth-icm.component').then(m => m.ClothIcmComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.BOIDS.PATH, loadComponent: () => import('./pages/algorithms/boids/boids.component').then(m => m.BoidsComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.PHYSARUM.PATH, loadComponent: () => import('./pages/algorithms/physarum/physarum.component').then(m => m.PhysarumComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.GRAY_SCOTT.PATH, loadComponent: () => import('./pages/algorithms/gray-scott/gray-scott.component').then(m => m.GrayScottComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.LENIA.PATH, loadComponent: () => import('./pages/algorithms/lenia/lenia.component').then(m => m.LeniaComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.FLUID.PATH, loadComponent: () => import('./pages/algorithms/fluid/fluid.component').then(m => m.FluidComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.ANT_COLONY.PATH, loadComponent: () => import('./pages/algorithms/ant-colony/ant-colony.component').then(m => m.AntColonyComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.VORONOI.PATH, loadComponent: () => import('./pages/algorithms/voronoi/voronoi.component').then(m => m.VoronoiComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } },
|
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } },
|
||||||
|
{ path: RouterConstants.FOURIER.PATH, loadComponent: () => import('./pages/algorithms/fourier/fourier.component').then(m => m.FourierComponent), data: { seo: 'FOURIER' } },
|
||||||
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } },
|
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } },
|
||||||
{ path: '**', loadComponent: () => import('./pages/not-found/not-found.component').then(m => m.NotFoundComponent), data: { seo: 'NOT_FOUND' } },
|
{ path: '**', loadComponent: () => import('./pages/not-found/not-found.component').then(m => m.NotFoundComponent), data: { seo: 'NOT_FOUND' } },
|
||||||
];
|
];
|
||||||
@@ -41,12 +41,12 @@ export class AssetsConstants {
|
|||||||
'/assets/projects/diploma/6.webp'
|
'/assets/projects/diploma/6.webp'
|
||||||
];
|
];
|
||||||
|
|
||||||
static readonly CLAUDE_BOX_IMAGES = [
|
static readonly CLAUDE_BOX_DIAGRAM = '/assets/projects/claude-in-a-box/1.svg';
|
||||||
'/assets/projects/claude-in-a-box/1.svg'
|
static readonly PLAYGROUND_DIAGRAM = '/assets/projects/playground/architecture.svg';
|
||||||
];
|
static readonly TBP_DIAGRAM = '/assets/projects/to-be-played/architecture.svg';
|
||||||
|
static readonly TRIBBLE_DIAGRAM = '/assets/projects/tribble-the-homeserver/architecture.svg';
|
||||||
|
|
||||||
static readonly TRIBBLE_IMAGES = [
|
static readonly TRIBBLE_IMAGES = [
|
||||||
'/assets/projects/tribble-the-homeserver/1.webp',
|
|
||||||
'/assets/projects/tribble-the-homeserver/2.webp',
|
'/assets/projects/tribble-the-homeserver/2.webp',
|
||||||
'/assets/projects/tribble-the-homeserver/3.webp',
|
'/assets/projects/tribble-the-homeserver/3.webp',
|
||||||
'/assets/projects/tribble-the-homeserver/4.webp'
|
'/assets/projects/tribble-the-homeserver/4.webp'
|
||||||
|
|||||||
@@ -55,16 +55,66 @@
|
|||||||
LINK: '/algorithms/cloth',
|
LINK: '/algorithms/cloth',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static readonly CLOTH_ICM = {
|
||||||
|
PATH: 'algorithms/cloth-icm',
|
||||||
|
LINK: '/algorithms/cloth-icm',
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly BOIDS = {
|
||||||
|
PATH: 'algorithms/boids',
|
||||||
|
LINK: '/algorithms/boids',
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly PHYSARUM = {
|
||||||
|
PATH: 'algorithms/physarum',
|
||||||
|
LINK: '/algorithms/physarum',
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly GRAY_SCOTT = {
|
||||||
|
PATH: 'algorithms/gray-scott',
|
||||||
|
LINK: '/algorithms/gray-scott',
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly LENIA = {
|
||||||
|
PATH: 'algorithms/lenia',
|
||||||
|
LINK: '/algorithms/lenia',
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly FLUID = {
|
||||||
|
PATH: 'algorithms/fluid',
|
||||||
|
LINK: '/algorithms/fluid',
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly ANT_COLONY = {
|
||||||
|
PATH: 'algorithms/ant-colony',
|
||||||
|
LINK: '/algorithms/ant-colony',
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly VORONOI = {
|
||||||
|
PATH: 'algorithms/voronoi',
|
||||||
|
LINK: '/algorithms/voronoi',
|
||||||
|
};
|
||||||
|
|
||||||
static readonly FOUR_COLOR = {
|
static readonly FOUR_COLOR = {
|
||||||
PATH: 'algorithms/four_color',
|
PATH: 'algorithms/four_color',
|
||||||
LINK: '/algorithms/four_color',
|
LINK: '/algorithms/four_color',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static readonly FOURIER = {
|
||||||
|
PATH: 'algorithms/fourier',
|
||||||
|
LINK: '/algorithms/fourier',
|
||||||
|
};
|
||||||
|
|
||||||
static readonly IMPRINT = {
|
static readonly IMPRINT = {
|
||||||
PATH: 'imprint',
|
PATH: 'imprint',
|
||||||
LINK: '/imprint',
|
LINK: '/imprint',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static readonly PRIVACY = {
|
||||||
|
PATH: 'privacy',
|
||||||
|
LINK: '/privacy',
|
||||||
|
};
|
||||||
|
|
||||||
static readonly STOPWATCH = {
|
static readonly STOPWATCH = {
|
||||||
PATH: 'stopwatch',
|
PATH: 'stopwatch',
|
||||||
LINK: '/stopwatch',
|
LINK: '/stopwatch',
|
||||||
|
|||||||
@@ -21,9 +21,28 @@
|
|||||||
static readonly DOUBLE_PENDULUM_WIKI = 'https://de.wikipedia.org/wiki/Doppelpendel'
|
static readonly DOUBLE_PENDULUM_WIKI = 'https://de.wikipedia.org/wiki/Doppelpendel'
|
||||||
static readonly CLOTH_SIMULATION_WIKI = 'https://en.wikipedia.org/wiki/Cloth_modeling'
|
static readonly CLOTH_SIMULATION_WIKI = 'https://en.wikipedia.org/wiki/Cloth_modeling'
|
||||||
static readonly XPBD_WIKI = 'https://www.emergentmind.com/topics/extended-position-based-dynamics-xpbd'
|
static readonly XPBD_WIKI = 'https://www.emergentmind.com/topics/extended-position-based-dynamics-xpbd'
|
||||||
|
static readonly ICM_PAPER = 'https://doi.org/10.1145/1141911.1142007'
|
||||||
|
static readonly KDOP_WIKI = 'https://en.wikipedia.org/wiki/Bounding_volume'
|
||||||
|
static readonly BOIDS_WIKI = 'https://de.wikipedia.org/wiki/Boids'
|
||||||
|
static readonly PARTICLE_LIFE_WIKI = 'https://particle-life.com'
|
||||||
|
static readonly PHYSARUM_WIKI = 'https://en.wikipedia.org/wiki/Physarum_polycephalum'
|
||||||
|
static readonly PHYSARUM_PAPER = 'https://doi.org/10.1162/artl.2010.16.2.16202'
|
||||||
|
static readonly GRAY_SCOTT_WIKI = 'https://en.wikipedia.org/wiki/Reaction%E2%80%93diffusion_system'
|
||||||
|
static readonly GRAY_SCOTT_SIMS = 'https://www.karlsims.com/rd.html'
|
||||||
|
static readonly LENIA_PAPER = 'https://arxiv.org/abs/1812.05433'
|
||||||
|
static readonly STABLE_FLUIDS_PAPER = 'https://www.dgp.toronto.edu/public_user/stam/reality/Research/pdf/ns.pdf'
|
||||||
|
static readonly NAVIER_STOKES_WIKI = 'https://en.wikipedia.org/wiki/Navier%E2%80%93Stokes_equations'
|
||||||
|
static readonly GPU_GEMS_FLUIDS = 'https://developer.nvidia.com/gpugems/gpugems/part-vi-beyond-triangles/chapter-38-fast-fluid-dynamics-simulation-gpu'
|
||||||
|
static readonly ANT_COLONY_WIKI = 'https://de.wikipedia.org/wiki/Ameisenalgorithmus'
|
||||||
|
static readonly TSP_WIKI = 'https://de.wikipedia.org/wiki/Problem_des_Handlungsreisenden'
|
||||||
|
static readonly DELAUNAY_WIKI = 'https://de.wikipedia.org/wiki/Delaunay-Triangulierung'
|
||||||
|
static readonly VORONOI_WIKI = 'https://de.wikipedia.org/wiki/Voronoi-Diagramm'
|
||||||
|
static readonly BOWYER_WATSON_WIKI = 'https://en.wikipedia.org/wiki/Bowyer%E2%80%93Watson_algorithm'
|
||||||
static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units'
|
static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units'
|
||||||
static readonly DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur'
|
static readonly DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur'
|
||||||
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
|
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
|
||||||
|
static readonly FOURIER_SERIES_WIKI = 'https://de.wikipedia.org/wiki/Fourierreihe'
|
||||||
static readonly GITEA = 'https://git.andreas-dahm.eu'
|
static readonly GITEA = 'https://git.andreas-dahm.eu'
|
||||||
|
static readonly PLAYGROUND_SOURCE_BASE = `${UrlConstants.GITEA}/Lobo_Corp/playground-frontend/src/branch/main/`
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,17 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="border-t border-black/8 p-fluid-md text-center opacity-80 bg-app-bg">
|
<footer class="border-t border-black/8 p-fluid-md text-center opacity-80 bg-app-bg">
|
||||||
<small>© {{ currentYear }} Andreas Dahm - {{ `APP.COPYRIGHT` | translate }}</small>
|
<small>© {{ currentYear }} Andreas Dahm. {{ 'APP.COPYRIGHT' | translate }}</small>
|
||||||
<br />
|
<br />
|
||||||
<small>
|
<small>
|
||||||
{{ 'APP.AI_NOTICE' | translate }}
|
{{ 'APP.AI_NOTICE' | translate }}
|
||||||
<a [routerLink]="['/projects']" [queryParams]="{ project: 'claude-in-a-box' }">{{ 'APP.AI_NOTICE_LINK' | translate }}</a>
|
<a [routerLink]="['/projects']" [queryParams]="{ project: 'claude-in-a-box' }">{{ 'APP.AI_NOTICE_LINK' | translate }}</a>
|
||||||
</small>
|
</small>
|
||||||
|
<nav class="mt-2 flex justify-center gap-2" aria-label="Legal">
|
||||||
|
<a [routerLink]="RouterConstants.IMPRINT.LINK">{{ 'TOPBAR.IMPRINT' | translate }}</a>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<a [routerLink]="RouterConstants.PRIVACY.LINK">{{ 'PRIVACY.TITLE' | translate }}</a>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<a [routerLink]="RouterConstants.STOPWATCH.LINK">{{ 'TOPBAR.STOPWATCH' | translate }}</a>
|
||||||
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { Component, provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideTranslateService } from '@ngx-translate/core';
|
||||||
|
import { AppComponent } from './app.component';
|
||||||
|
import { TopbarComponent } from '../topbar/topbar.component';
|
||||||
|
import { ParticleBackgroundComponent } from '../../shared/components/particles-background/particles-background.component';
|
||||||
|
import { SeoService } from '../../service/seo.service';
|
||||||
|
|
||||||
|
@Component({ selector: 'app-topbar', template: '' })
|
||||||
|
class TopbarStub {}
|
||||||
|
|
||||||
|
@Component({ selector: 'app-particles-background', template: '' })
|
||||||
|
class ParticlesStub {}
|
||||||
|
|
||||||
|
describe('AppComponent', () => {
|
||||||
|
let fixture: ComponentFixture<AppComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
TestBed.overrideComponent(AppComponent, {
|
||||||
|
remove: { imports: [TopbarComponent, ParticleBackgroundComponent] },
|
||||||
|
add: { imports: [TopbarStub, ParticlesStub] },
|
||||||
|
});
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AppComponent],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideRouter([]),
|
||||||
|
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
|
||||||
|
{ provide: SeoService, useValue: { init: vi.fn() } },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AppComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the copyright line with year, name and a closing dot', () => {
|
||||||
|
const text = fixture.nativeElement.querySelector('footer small').textContent.trim();
|
||||||
|
expect(text).toBe(`© ${new Date().getFullYear()} Andreas Dahm. APP.COPYRIGHT`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links imprint, privacy and stopwatch from the footer', () => {
|
||||||
|
const hrefs = Array.from<HTMLAnchorElement>(fixture.nativeElement.querySelectorAll('footer nav a'))
|
||||||
|
.map(anchor => anchor.getAttribute('href'));
|
||||||
|
|
||||||
|
expect(hrefs).toEqual(['/imprint', '/privacy', '/stopwatch']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {TopbarComponent} from '../topbar/topbar.component';
|
|||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component';
|
import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component';
|
||||||
import {SeoService} from '../../service/seo.service';
|
import {SeoService} from '../../service/seo.service';
|
||||||
|
import {RouterConstants} from '../../constants/RouterConstants';
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -14,6 +15,7 @@ import {SeoService} from '../../service/seo.service';
|
|||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
private readonly seo = inject(SeoService);
|
private readonly seo = inject(SeoService);
|
||||||
|
protected readonly RouterConstants = RouterConstants;
|
||||||
currentYear = new Date().getFullYear();
|
currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -12,10 +12,6 @@
|
|||||||
[routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a>
|
[routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a>
|
||||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||||
[routerLink]="RouterConstants.ALGORITHMS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ALGORITHMS' | translate }}</a>
|
[routerLink]="RouterConstants.ALGORITHMS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ALGORITHMS' | translate }}</a>
|
||||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
|
||||||
[routerLink]="RouterConstants.STOPWATCH.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.STOPWATCH' | translate }}</a>
|
|
||||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
|
||||||
[routerLink]="RouterConstants.IMPRINT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile nav menu button -->
|
<!-- Mobile nav menu button -->
|
||||||
@@ -36,12 +32,6 @@
|
|||||||
<button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK">
|
<button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK">
|
||||||
{{ 'TOPBAR.ALGORITHMS' | translate }}
|
{{ 'TOPBAR.ALGORITHMS' | translate }}
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item [routerLink]="RouterConstants.STOPWATCH.LINK">
|
|
||||||
{{ 'TOPBAR.STOPWATCH' | translate }}
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item [routerLink]="RouterConstants.IMPRINT.LINK">
|
|
||||||
{{ 'TOPBAR.IMPRINT' | translate }}
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
|
||||||
<span class="flex-1"></span>
|
<span class="flex-1"></span>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideTranslateService } from '@ngx-translate/core';
|
||||||
|
import { TopbarComponent } from './topbar.component';
|
||||||
|
|
||||||
|
describe('TopbarComponent', () => {
|
||||||
|
let fixture: ComponentFixture<TopbarComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [TopbarComponent],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideRouter([]),
|
||||||
|
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(TopbarComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links only the main sections; imprint and stopwatch live in the footer', () => {
|
||||||
|
const hrefs = Array.from<HTMLAnchorElement>(fixture.nativeElement.querySelectorAll('nav a'))
|
||||||
|
.map(anchor => anchor.getAttribute('href'));
|
||||||
|
|
||||||
|
expect(hrefs).toEqual(['/about', '/projects', '/algorithms']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,10 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||||
<mat-icon aria-hidden="true">email</mat-icon>
|
<mat-icon aria-hidden="true">email</mat-icon>
|
||||||
<button type="button" class="link-button" data-testid="contact-mail"
|
<a href="mailto:info@andreas-dahm.eu" data-testid="contact-mail">info@andreas-dahm.eu</a>
|
||||||
(click)="SharedFunctions.openMail($event)">
|
|
||||||
{{ 'ABOUT.CONTACT_ME' | translate }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||||
<mat-icon aria-hidden="true">data_object</mat-icon>
|
<mat-icon aria-hidden="true">data_object</mat-icon>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { MatIconRegistry } from '@angular/material/icon';
|
|||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { provideTranslateService } from '@ngx-translate/core';
|
import { provideTranslateService } from '@ngx-translate/core';
|
||||||
import { AboutComponent } from './about.component';
|
import { AboutComponent } from './about.component';
|
||||||
|
import en from '../../../assets/i18n/en.json';
|
||||||
|
import de from '../../../assets/i18n/de.json';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No translations are loaded, so the translate pipe echoes the key back. That is
|
* No translations are loaded, so the translate pipe echoes the key back. That is
|
||||||
@@ -151,6 +153,26 @@ describe('AboutComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('projects', () => {
|
describe('projects', () => {
|
||||||
|
it('every project key exists in en and de', () => {
|
||||||
|
const keys = fixture.componentInstance.projectKeys.flatMap(project => [
|
||||||
|
`${project.key}.TITLE`,
|
||||||
|
`${project.key}.DESCRIPTION`,
|
||||||
|
`${project.key}.LINK_INTERNAL`,
|
||||||
|
`${project.key}.HIGHLIGHTS.P1`,
|
||||||
|
`${project.key}.HIGHLIGHTS.P2`,
|
||||||
|
`${project.key}.HIGHLIGHTS.P3`,
|
||||||
|
...(project.externalLink ? [`${project.key}.LINK_EXTERNAL`] : []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const missing = Object.entries({ en, de }).flatMap(([language, translations]) =>
|
||||||
|
keys
|
||||||
|
.filter(key => typeof resolve(translations, key) !== 'string')
|
||||||
|
.map(key => `${language}: ${key}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('lists To Be Played and links to its project dialog', () => {
|
it('lists To Be Played and links to its project dialog', () => {
|
||||||
expect(textOf('mat-card strong')).toContain('ABOUT.PROJECT.TBP.TITLE');
|
expect(textOf('mat-card strong')).toContain('ABOUT.PROJECT.TBP.TITLE');
|
||||||
|
|
||||||
@@ -166,11 +188,14 @@ describe('AboutComponent', () => {
|
|||||||
describe('education', () => {
|
describe('education', () => {
|
||||||
it('shows only the post-school entries until the toggle button is clicked', () => {
|
it('shows only the post-school entries until the toggle button is clicked', () => {
|
||||||
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E6.WHERE');
|
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E6.WHERE');
|
||||||
expect(textOf('[data-testid="education-where"]')).not.toContain('ABOUT.EDUCATION.E0.WHERE');
|
expect(textOf('[data-testid="education-where"]')).not.toContain('ABOUT.EDUCATION.E2.WHERE');
|
||||||
|
expect(textOf('[data-testid="education-where"]').length).toBe(3);
|
||||||
|
|
||||||
click('toggle-education');
|
click('toggle-education');
|
||||||
|
|
||||||
|
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E2.WHERE');
|
||||||
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E0.WHERE');
|
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E0.WHERE');
|
||||||
|
expect(textOf('[data-testid="education-where"]').length).toBe(7);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,13 +204,22 @@ describe('AboutComponent', () => {
|
|||||||
expect(fixture.debugElement.queryAll(By.css('a[download]')).length).toBe(0);
|
expect(fixture.debugElement.queryAll(By.css('a[download]')).length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes the mail contact as a button rather than an empty link', () => {
|
it('renders the mail contact as a mailto link that shows the address', () => {
|
||||||
const emptyLinks = fixture.debugElement
|
const emptyLinks = fixture.debugElement
|
||||||
.queryAll(By.css('a'))
|
.queryAll(By.css('a'))
|
||||||
.filter(anchor => anchor.nativeElement.getAttribute('href') === '');
|
.filter(anchor => anchor.nativeElement.getAttribute('href') === '');
|
||||||
|
|
||||||
expect(emptyLinks.length).toBe(0);
|
expect(emptyLinks.length).toBe(0);
|
||||||
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
|
|
||||||
|
const mail = fixture.debugElement.query(By.css('a[data-testid="contact-mail"]')).nativeElement;
|
||||||
|
expect(mail.getAttribute('href')).toBe('mailto:info@andreas-dahm.eu');
|
||||||
|
expect(mail.textContent.trim()).toBe('info@andreas-dahm.eu');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function resolve(translations: unknown, key: string): unknown {
|
||||||
|
return key
|
||||||
|
.split('.')
|
||||||
|
.reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], translations);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -9,7 +9,6 @@ import { TranslatePipe } from '@ngx-translate/core';
|
|||||||
import {UrlConstants} from '../../constants/UrlConstants';
|
import {UrlConstants} from '../../constants/UrlConstants';
|
||||||
import {AssetsConstants} from '../../constants/AssetsConstants';
|
import {AssetsConstants} from '../../constants/AssetsConstants';
|
||||||
import {RouterLink} from '@angular/router';
|
import {RouterLink} from '@angular/router';
|
||||||
import {SharedFunctions} from '../../shared/SharedFunctions';
|
|
||||||
|
|
||||||
/** One role. Consecutive entries sharing a `company` render as a single employer block. */
|
/** One role. Consecutive entries sharing a `company` render as a single employer block. */
|
||||||
interface ExperienceEntry {
|
interface ExperienceEntry {
|
||||||
@@ -267,5 +266,4 @@ export class AboutComponent {
|
|||||||
|
|
||||||
protected readonly UrlConstants = UrlConstants;
|
protected readonly UrlConstants = UrlConstants;
|
||||||
protected readonly AssetsConstants = AssetsConstants;
|
protected readonly AssetsConstants = AssetsConstants;
|
||||||
protected readonly SharedFunctions = SharedFunctions;
|
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,20 @@ export class AlgorithmsService {
|
|||||||
routerLink: RouterConstants.FRACTAL.LINK,
|
routerLink: RouterConstants.FRACTAL.LINK,
|
||||||
icon: 'blur_on'
|
icon: 'blur_on'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'antColony',
|
||||||
|
title: 'ALGORITHM.ANT_COLONY.TITLE',
|
||||||
|
description: 'ALGORITHM.ANT_COLONY.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.ANT_COLONY.LINK,
|
||||||
|
icon: 'pest_control'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'voronoi',
|
||||||
|
title: 'ALGORITHM.VORONOI.TITLE',
|
||||||
|
description: 'ALGORITHM.VORONOI.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.VORONOI.LINK,
|
||||||
|
icon: 'hexagon'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'fractal3d',
|
id: 'fractal3d',
|
||||||
title: 'ALGORITHM.FRACTAL3D.TITLE',
|
title: 'ALGORITHM.FRACTAL3D.TITLE',
|
||||||
@@ -64,12 +78,61 @@ export class AlgorithmsService {
|
|||||||
routerLink: RouterConstants.CLOTH.LINK,
|
routerLink: RouterConstants.CLOTH.LINK,
|
||||||
icon: 'texture'
|
icon: 'texture'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'clothIcm',
|
||||||
|
title: 'ALGORITHM.CLOTH_ICM.TITLE',
|
||||||
|
description: 'ALGORITHM.CLOTH_ICM.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.CLOTH_ICM.LINK,
|
||||||
|
icon: 'layers'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'boids',
|
||||||
|
title: 'ALGORITHM.BOIDS.TITLE',
|
||||||
|
description: 'ALGORITHM.BOIDS.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.BOIDS.LINK,
|
||||||
|
icon: 'flutter_dash'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'physarum',
|
||||||
|
title: 'ALGORITHM.PHYSARUM.TITLE',
|
||||||
|
description: 'ALGORITHM.PHYSARUM.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.PHYSARUM.LINK,
|
||||||
|
icon: 'hub'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grayScott',
|
||||||
|
title: 'ALGORITHM.GRAY_SCOTT.TITLE',
|
||||||
|
description: 'ALGORITHM.GRAY_SCOTT.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.GRAY_SCOTT.LINK,
|
||||||
|
icon: 'blur_on'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lenia',
|
||||||
|
title: 'ALGORITHM.LENIA.TITLE',
|
||||||
|
description: 'ALGORITHM.LENIA.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.LENIA.LINK,
|
||||||
|
icon: 'bubble_chart'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fluid',
|
||||||
|
title: 'ALGORITHM.FLUID.TITLE',
|
||||||
|
description: 'ALGORITHM.FLUID.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.FLUID.LINK,
|
||||||
|
icon: 'water'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'fourColor',
|
id: 'fourColor',
|
||||||
title: 'ALGORITHM.FOUR_COLOR.TITLE',
|
title: 'ALGORITHM.FOUR_COLOR.TITLE',
|
||||||
description: 'ALGORITHM.FOUR_COLOR.DESCRIPTION',
|
description: 'ALGORITHM.FOUR_COLOR.DESCRIPTION',
|
||||||
routerLink: RouterConstants.FOUR_COLOR.LINK,
|
routerLink: RouterConstants.FOUR_COLOR.LINK,
|
||||||
icon: 'palette'
|
icon: 'palette'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fourier',
|
||||||
|
title: 'ALGORITHM.FOURIER.TITLE',
|
||||||
|
description: 'ALGORITHM.FOURIER.DESCRIPTION',
|
||||||
|
routerLink: RouterConstants.FOURIER.LINK,
|
||||||
|
icon: 'track_changes'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import {AntColonySimulation} from './ant-colony-simulation';
|
||||||
|
import {
|
||||||
|
City, DEFAULT_SETTINGS, MAX_CITIES, MIN_CITY_DISTANCE, nearestNeighborTour, PHEROMONE_Q, randomCities, TAU_MIN, tourLength
|
||||||
|
} from './ant-colony.model';
|
||||||
|
|
||||||
|
function mulberry(seed: number): () => number {
|
||||||
|
let a = seed;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6D2B79F5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSim(count = 12, seed = 7, settings = DEFAULT_SETTINGS): AntColonySimulation {
|
||||||
|
const random = mulberry(seed);
|
||||||
|
return new AntColonySimulation(randomCities(count, random), settings, random);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AntColonySimulation', () => {
|
||||||
|
it('constructTour visits every city once and starts at start', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
sim['prepareWeights']();
|
||||||
|
const tour = sim.constructTour(4);
|
||||||
|
expect(tour[0]).toBe(4);
|
||||||
|
expect([...tour].sort((a, b) => a - b)).toEqual(sim.cities.map((_, i) => i));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('constructTour follows the nearest city when distance dominates', () => {
|
||||||
|
const cities: City[] = [{x: 0, y: 0}, {x: 300, y: 0}, {x: 100, y: 0}, {x: 500, y: 0}, {x: 190, y: 0}];
|
||||||
|
const sim = new AntColonySimulation(cities, {...DEFAULT_SETTINGS, alpha: 0, beta: 50}, () => 0.5);
|
||||||
|
sim['prepareWeights']();
|
||||||
|
expect(sim.constructTour(0)).toEqual(nearestNeighborTour(cities, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evaporate multiplies by (1 - evaporation) and keeps the floor', () => {
|
||||||
|
const sim = makeSim(5, 1, {...DEFAULT_SETTINGS, evaporation: 0.5});
|
||||||
|
sim.pheromone[1] = 8;
|
||||||
|
sim.pheromone[2] = TAU_MIN;
|
||||||
|
const before = Float32Array.from(sim.pheromone);
|
||||||
|
sim.evaporate();
|
||||||
|
expect(sim.pheromone[1]).toBeCloseTo(4);
|
||||||
|
expect(sim.pheromone[2]).toBeCloseTo(TAU_MIN, 9);
|
||||||
|
expect(sim.pheromone[3]).toBeCloseTo(Math.max(TAU_MIN, before[3] * 0.5), 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deposit adds Q / length on both directions of tour edges only', () => {
|
||||||
|
const sim = makeSim(5, 2);
|
||||||
|
const before = Float32Array.from(sim.pheromone);
|
||||||
|
sim.deposit([0, 1, 2], 10);
|
||||||
|
const n = 5;
|
||||||
|
const edges = new Set(['0,1', '1,0', '1,2', '2,1', '2,0', '0,2']);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
for (let j = 0; j < n; j++) {
|
||||||
|
const added = sim.pheromone[i * n + j] - before[i * n + j];
|
||||||
|
expect(added).toBeCloseTo(edges.has(`${i},${j}`) ? PHEROMONE_Q / 10 : 0, 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('step counts iterations, never worsens the best and tracks the max pheromone', () => {
|
||||||
|
const sim = makeSim();
|
||||||
|
let last = sim.bestLength;
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
sim.step();
|
||||||
|
expect(sim.bestLength).toBeLessThanOrEqual(last);
|
||||||
|
last = sim.bestLength;
|
||||||
|
}
|
||||||
|
expect(sim.iteration).toBe(50);
|
||||||
|
expect(sim.maxPheromone).toBe(Math.max(...sim.pheromone));
|
||||||
|
expect(sim.bestLength).toBeCloseTo(tourLength(sim.bestTour, sim.cities), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds a tour within 10% of nearest neighbor on default settings', () => {
|
||||||
|
const sim = makeSim(20, 11);
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
sim.step();
|
||||||
|
}
|
||||||
|
expect(sim.bestLength).toBeLessThanOrEqual(1.1 * sim.nnLength);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addCity grows the matrices and inserts the city into the best tour', () => {
|
||||||
|
const sim = makeSim(10, 3);
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
sim.step();
|
||||||
|
}
|
||||||
|
const old = Float32Array.from(sim.pheromone);
|
||||||
|
const mean = old.reduce((s, v) => s + v, 0) / old.length;
|
||||||
|
const nnBefore = sim.nnLength;
|
||||||
|
expect(sim.addCity({x: 500.5, y: 500.5})).toBe(true);
|
||||||
|
const n = 11;
|
||||||
|
expect(sim.cities.length).toBe(n);
|
||||||
|
expect(sim.pheromone.length).toBe(n * n);
|
||||||
|
expect(sim.pheromone[10]).toBeCloseTo(mean, 6);
|
||||||
|
expect(sim.pheromone[n * 10 + 3]).toBeCloseTo(mean, 6);
|
||||||
|
expect(sim.pheromone[n * 2 + 3]).toBe(old[10 * 2 + 3]);
|
||||||
|
expect(sim.bestTour.length).toBe(n);
|
||||||
|
expect(sim.bestTour).toContain(10);
|
||||||
|
expect(sim.nnLength).not.toBe(nnBefore);
|
||||||
|
sim.step();
|
||||||
|
expect(sim.antTours[0].length).toBe(n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addCity refuses a point near an existing city', () => {
|
||||||
|
const sim = makeSim(6, 4);
|
||||||
|
const near = {x: sim.cities[0].x + MIN_CITY_DISTANCE - 1, y: sim.cities[0].y};
|
||||||
|
expect(sim.addCity(near)).toBe(false);
|
||||||
|
expect(sim.cities.length).toBe(6);
|
||||||
|
expect(sim.pheromone.length).toBe(36);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addCity refuses at MAX_CITIES', () => {
|
||||||
|
const cities = Array.from({length: MAX_CITIES}, (_, i) => ({x: (i % 12) * 80 + 20, y: Math.floor(i / 12) * 80 + 20}));
|
||||||
|
const sim = new AntColonySimulation(cities, DEFAULT_SETTINGS, mulberry(1));
|
||||||
|
expect(sim.addCity({x: 999, y: 999})).toBe(false);
|
||||||
|
expect(sim.cities.length).toBe(MAX_CITIES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing with fewer than 3 cities', () => {
|
||||||
|
const sim = new AntColonySimulation([{x: 0, y: 0}, {x: 30, y: 40}], DEFAULT_SETTINGS, mulberry(1));
|
||||||
|
const tour = [...sim.bestTour];
|
||||||
|
sim.step();
|
||||||
|
expect(sim.iteration).toBe(0);
|
||||||
|
expect(sim.bestTour).toEqual(tour);
|
||||||
|
expect(sim.bestLength).toBeCloseTo(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import {
|
||||||
|
AntColonySettings, City, cheapestInsertion, distance, MAX_CITIES, MIN_CITIES, MIN_CITY_DISTANCE, nearestNeighborTour,
|
||||||
|
PHEROMONE_Q, TAU_MIN, tourLength
|
||||||
|
} from './ant-colony.model';
|
||||||
|
|
||||||
|
/** Ant System on a symmetric TSP. */
|
||||||
|
export class AntColonySimulation {
|
||||||
|
readonly cities: City[];
|
||||||
|
pheromone = new Float32Array(0);
|
||||||
|
maxPheromone = 0;
|
||||||
|
iteration = 0;
|
||||||
|
bestTour: number[] = [];
|
||||||
|
bestLength = 0;
|
||||||
|
nnLength = 0;
|
||||||
|
antTours: number[][] = [];
|
||||||
|
|
||||||
|
private distances = new Float32Array(0);
|
||||||
|
private weights = new Float64Array(0);
|
||||||
|
private visited = new Uint8Array(0);
|
||||||
|
|
||||||
|
constructor(cities: City[], private settings: AntColonySettings, private readonly random: () => number = Math.random) {
|
||||||
|
this.cities = [...cities];
|
||||||
|
const n = this.cities.length;
|
||||||
|
this.rebuildDistances();
|
||||||
|
this.updateBaseline();
|
||||||
|
this.pheromone = new Float32Array(n * n).fill(n >= MIN_CITIES ? 1 / (n * this.nnLength) : 1);
|
||||||
|
this.maxPheromone = this.matrixMax();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSettings(settings: AntColonySettings): void {
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
step(): void {
|
||||||
|
const n = this.cities.length;
|
||||||
|
if (n < MIN_CITIES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.prepareWeights();
|
||||||
|
const ants = this.settings.ants;
|
||||||
|
this.antTours.length = ants;
|
||||||
|
const lengths: number[] = [];
|
||||||
|
for (let k = 0; k < ants; k++) {
|
||||||
|
const tour = this.constructTour(Math.floor(this.random() * n));
|
||||||
|
this.antTours[k] = tour;
|
||||||
|
lengths.push(tourLength(tour, this.cities));
|
||||||
|
}
|
||||||
|
this.evaporate();
|
||||||
|
for (let k = 0; k < ants; k++) {
|
||||||
|
this.deposit(this.antTours[k], lengths[k]);
|
||||||
|
if (lengths[k] < this.bestLength) {
|
||||||
|
this.bestLength = lengths[k];
|
||||||
|
this.bestTour = this.antTours[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.maxPheromone = this.matrixMax();
|
||||||
|
this.iteration++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Roulette wheel on the weights of the last prepareWeights() call. */
|
||||||
|
constructTour(start: number): number[] {
|
||||||
|
const n = this.cities.length;
|
||||||
|
this.visited.fill(0);
|
||||||
|
const tour = [start];
|
||||||
|
this.visited[start] = 1;
|
||||||
|
let current = start;
|
||||||
|
while (tour.length < n) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let j = 0; j < n; j++) {
|
||||||
|
if (!this.visited[j]) {
|
||||||
|
sum += this.weights[current * n + j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pick = this.random() * sum;
|
||||||
|
let next = -1;
|
||||||
|
for (let j = 0; j < n; j++) {
|
||||||
|
if (!this.visited[j]) {
|
||||||
|
next = j;
|
||||||
|
pick -= this.weights[current * n + j];
|
||||||
|
if (pick <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.visited[next] = 1;
|
||||||
|
tour.push(next);
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
return tour;
|
||||||
|
}
|
||||||
|
|
||||||
|
evaporate(): void {
|
||||||
|
const keep = 1 - this.settings.evaporation;
|
||||||
|
for (let i = 0; i < this.pheromone.length; i++) {
|
||||||
|
this.pheromone[i] = Math.max(TAU_MIN, keep * this.pheromone[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deposit(tour: readonly number[], length: number): void {
|
||||||
|
const n = this.cities.length;
|
||||||
|
const amount = PHEROMONE_Q / length;
|
||||||
|
for (let i = 0; i < tour.length; i++) {
|
||||||
|
const a = tour[i];
|
||||||
|
const b = tour[(i + 1) % tour.length];
|
||||||
|
this.pheromone[a * n + b] += amount;
|
||||||
|
this.pheromone[b * n + a] += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addCity(city: City): boolean {
|
||||||
|
const n = this.cities.length;
|
||||||
|
if (n >= MAX_CITIES || this.cities.some(c => distance(c, city) < MIN_CITY_DISTANCE)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const old = this.pheromone;
|
||||||
|
let mean = 0;
|
||||||
|
for (let i = 0; i < old.length; i++) {
|
||||||
|
mean += old[i];
|
||||||
|
}
|
||||||
|
mean = old.length ? mean / old.length : 1;
|
||||||
|
const m = n + 1;
|
||||||
|
const grown = new Float32Array(m * m).fill(mean);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
grown.set(old.subarray(i * n, (i + 1) * n), i * m);
|
||||||
|
}
|
||||||
|
this.cities.push(city);
|
||||||
|
this.pheromone = grown;
|
||||||
|
this.rebuildDistances();
|
||||||
|
this.bestTour = cheapestInsertion(this.bestTour, this.cities, n);
|
||||||
|
this.bestLength = tourLength(this.bestTour, this.cities);
|
||||||
|
this.updateBaseline();
|
||||||
|
this.antTours = [];
|
||||||
|
this.maxPheromone = this.matrixMax();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBaseline(): void {
|
||||||
|
const tour = nearestNeighborTour(this.cities);
|
||||||
|
this.nnLength = tourLength(tour, this.cities);
|
||||||
|
if (this.bestTour.length === 0) {
|
||||||
|
this.bestTour = tour;
|
||||||
|
this.bestLength = this.nnLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuildDistances(): void {
|
||||||
|
const n = this.cities.length;
|
||||||
|
this.distances = new Float32Array(n * n);
|
||||||
|
this.weights = new Float64Array(n * n);
|
||||||
|
this.visited = new Uint8Array(n);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
for (let j = 0; j < n; j++) {
|
||||||
|
this.distances[i * n + j] = distance(this.cities[i], this.cities[j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tau^alpha * (1/d)^beta once per step, so every ant only sums. */
|
||||||
|
private prepareWeights(): void {
|
||||||
|
const n = this.cities.length;
|
||||||
|
const {alpha, beta} = this.settings;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
for (let j = 0; j < n; j++) {
|
||||||
|
const k = i * n + j;
|
||||||
|
const d = this.distances[k];
|
||||||
|
this.weights[k] = i === j || d === 0 ? 0 : Math.pow(this.pheromone[k], alpha) * Math.pow(1 / d, beta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private matrixMax(): number {
|
||||||
|
let max = 0;
|
||||||
|
for (let i = 0; i < this.pheromone.length; i++) {
|
||||||
|
max = Math.max(max, this.pheromone[i]);
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'ANT_COLONY.TITLE' | translate }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
|
<div class="flex flex-col mb-4">
|
||||||
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
|
<button mat-raised-button color="accent" (click)="togglePlay()">
|
||||||
|
<mat-icon>{{ running() ? 'pause' : 'play_arrow' }}</mat-icon>{{ (running() ? 'ANT_COLONY.PAUSE' : 'ANT_COLONY.PLAY') | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="reset()">{{ 'ANT_COLONY.RESET' | translate }}</button>
|
||||||
|
<button mat-raised-button (click)="reroll()">{{ 'ANT_COLONY.REROLL' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
<app-slider-field label="ANT_COLONY.CITIES" [min]="MIN_CITIES" [max]="MAX_CITIES" [(value)]="cityCount" (committed)="reroll()"/>
|
||||||
|
@for (slider of sliders; track slider.key) {
|
||||||
|
<app-slider-field [label]="slider.label" [min]="limits[slider.key].min" [max]="limits[slider.key].max"
|
||||||
|
[step]="limits[slider.key].step" [(value)]="slider.model"/>
|
||||||
|
}
|
||||||
|
<app-slider-field label="ANT_COLONY.SPEED" [min]="MIN_SPEED" [max]="MAX_SPEED" [(value)]="speed"/>
|
||||||
|
<p class="text-[0.9em] opacity-80">{{ 'ANT_COLONY.CLICK_HINT' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
|
<div class="w-full max-w-[800px] mx-auto">
|
||||||
|
<canvas #antCanvas
|
||||||
|
class="touch-none cursor-crosshair max-w-full"
|
||||||
|
(pointerdown)="onPointerDown($event)"
|
||||||
|
(pointerup)="onPointerUp($event)"
|
||||||
|
(pointercancel)="onPointerLeave()"
|
||||||
|
(pointerleave)="onPointerLeave()"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, signal, ViewChild, WritableSignal} from '@angular/core';
|
||||||
|
import {isPlatformBrowser} from '@angular/common';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
import {MatIcon} from '@angular/material/icon';
|
||||||
|
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
|
||||||
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {SOURCE_PATHS} from '../information/source-paths';
|
||||||
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {ThemeService} from '../../../service/theme.service';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
|
import {AntColonySimulation} from './ant-colony-simulation';
|
||||||
|
import {
|
||||||
|
AntColonyColors, AntColonySettings, CLICK_MOVE_PX, City, clampCityCount, clampSettings, DEFAULT_CITIES, DEFAULT_SETTINGS, DEFAULT_SPEED, MAX_CANVAS_PX,
|
||||||
|
MAX_CITIES, MAX_SPEED, MIN_CITIES, MIN_DRAW_RATIO, MIN_SPEED, pointOnTour, randomCities, SETTINGS_LIMITS, STEP_BUDGET_MS, THEME_COLORS,
|
||||||
|
WORLD_SIZE
|
||||||
|
} from './ant-colony.model';
|
||||||
|
|
||||||
|
interface SettingSlider {
|
||||||
|
key: keyof AntColonySettings;
|
||||||
|
label: string;
|
||||||
|
model: WritableSignal<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ant-colony',
|
||||||
|
imports: [
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
TranslatePipe,
|
||||||
|
Information,
|
||||||
|
MatButton,
|
||||||
|
MatIcon,
|
||||||
|
SliderFieldComponent,
|
||||||
|
MetricsComponent
|
||||||
|
],
|
||||||
|
templateUrl: './ant-colony.component.html'
|
||||||
|
})
|
||||||
|
export class AntColonyComponent implements AfterViewInit, OnDestroy {
|
||||||
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||||
|
private readonly themeService = inject(ThemeService);
|
||||||
|
private readonly ngZone = inject(NgZone);
|
||||||
|
private readonly fpsCounter = new FpsCounter();
|
||||||
|
|
||||||
|
@ViewChild('antCanvas', {static: true}) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
private ctx!: CanvasRenderingContext2D;
|
||||||
|
private sim: AntColonySimulation | null = null;
|
||||||
|
private animationFrameId = 0;
|
||||||
|
private lastFrameTime = 0;
|
||||||
|
private pending = 0;
|
||||||
|
private phase = 0;
|
||||||
|
private cssSize = MAX_CANVAS_PX;
|
||||||
|
private pressed: {pointerId: number; x: number; y: number} | null = null;
|
||||||
|
|
||||||
|
protected readonly MIN_CITIES = MIN_CITIES;
|
||||||
|
protected readonly MAX_CITIES = MAX_CITIES;
|
||||||
|
protected readonly MIN_SPEED = MIN_SPEED;
|
||||||
|
protected readonly MAX_SPEED = MAX_SPEED;
|
||||||
|
protected readonly limits = SETTINGS_LIMITS;
|
||||||
|
|
||||||
|
readonly cityCount = signal(DEFAULT_CITIES);
|
||||||
|
readonly ants = signal(DEFAULT_SETTINGS.ants);
|
||||||
|
readonly alpha = signal(DEFAULT_SETTINGS.alpha);
|
||||||
|
readonly beta = signal(DEFAULT_SETTINGS.beta);
|
||||||
|
readonly evaporation = signal(DEFAULT_SETTINGS.evaporation);
|
||||||
|
readonly speed = signal(DEFAULT_SPEED);
|
||||||
|
readonly running = signal(true);
|
||||||
|
readonly iteration = signal(0);
|
||||||
|
readonly bestLength = signal(0);
|
||||||
|
readonly nnLength = signal(0);
|
||||||
|
readonly gap = computed(() => this.nnLength() ? (this.bestLength() - this.nnLength()) / this.nnLength() * 100 : 0);
|
||||||
|
|
||||||
|
readonly sliders: SettingSlider[] = [
|
||||||
|
{key: 'ants', label: 'ANT_COLONY.ANTS', model: this.ants},
|
||||||
|
{key: 'alpha', label: 'ANT_COLONY.ALPHA', model: this.alpha},
|
||||||
|
{key: 'beta', label: 'ANT_COLONY.BETA', model: this.beta},
|
||||||
|
{key: 'evaporation', label: 'ANT_COLONY.EVAPORATION', model: this.evaporation}
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
|
||||||
|
{label: 'METRICS.ITERATION', value: this.iteration()},
|
||||||
|
{label: 'ANT_COLONY.BEST_LENGTH', value: this.bestLength(), fractionDigits: 0},
|
||||||
|
{label: 'ANT_COLONY.NN_LENGTH', value: this.nnLength(), fractionDigits: 0},
|
||||||
|
{label: 'ANT_COLONY.GAP', value: this.gap(), unit: '%', fractionDigits: 1}
|
||||||
|
]);
|
||||||
|
|
||||||
|
readonly algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'ANT_COLONY.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'ANT_COLONY.EXPLANATION.ACO_TITLE',
|
||||||
|
description: 'ANT_COLONY.EXPLANATION.ACO',
|
||||||
|
link: UrlConstants.ANT_COLONY_WIKI,
|
||||||
|
translateName: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ANT_COLONY.EXPLANATION.TSP_TITLE',
|
||||||
|
description: 'ANT_COLONY.EXPLANATION.TSP',
|
||||||
|
link: UrlConstants.TSP_WIKI,
|
||||||
|
translateName: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'ANT_COLONY.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: '',
|
||||||
|
disclaimerListEntry: ['ANT_COLONY.EXPLANATION.DISCLAIMER_1', 'ANT_COLONY.EXPLANATION.DISCLAIMER_2', 'ANT_COLONY.EXPLANATION.DISCLAIMER_3'],
|
||||||
|
howItWorks: {
|
||||||
|
intro: 'ANT_COLONY.HOW_IT_WORKS.INTRO',
|
||||||
|
items: ['ANT_COLONY.HOW_IT_WORKS.ITEM_1', 'ANT_COLONY.HOW_IT_WORKS.ITEM_2', 'ANT_COLONY.HOW_IT_WORKS.ITEM_3', 'ANT_COLONY.HOW_IT_WORKS.ITEM_4', 'ANT_COLONY.HOW_IT_WORKS.ITEM_5']
|
||||||
|
},
|
||||||
|
sources: SOURCE_PATHS.ANT_COLONY
|
||||||
|
};
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (!this.isBrowser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
|
||||||
|
this.resizeCanvas();
|
||||||
|
this.reroll();
|
||||||
|
this.ngZone.runOutsideAngular(() => {
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.frame);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.isBrowser) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changes only the canvas size. The simulation lives in world units. */
|
||||||
|
@HostListener('window:resize')
|
||||||
|
resizeCanvas(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
this.cssSize = Math.min(canvas.parentElement!.clientWidth, MAX_CANVAS_PX);
|
||||||
|
canvas.width = canvas.height = Math.round(this.cssSize * dpr);
|
||||||
|
canvas.style.width = canvas.style.height = `${this.cssSize}px`;
|
||||||
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
reroll(): void {
|
||||||
|
this.createSimulation(randomCities(clampCityCount(this.cityCount())));
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
if (this.sim) {
|
||||||
|
this.createSimulation(this.sim.cities);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlay(): void {
|
||||||
|
this.running.update(v => !v);
|
||||||
|
if (this.running()) {
|
||||||
|
this.lastFrameTime = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerDown(event: PointerEvent): void {
|
||||||
|
if (event.isPrimary) {
|
||||||
|
this.pressed = {pointerId: event.pointerId, x: event.clientX, y: event.clientY};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp(event: PointerEvent): void {
|
||||||
|
const down = this.pressed;
|
||||||
|
this.pressed = null;
|
||||||
|
if (!down || down.pointerId !== event.pointerId || Math.hypot(event.clientX - down.x, event.clientY - down.y) >= CLICK_MOVE_PX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.sim?.addCity(this.toWorld(event))) {
|
||||||
|
this.cityCount.set(this.sim.cities.length);
|
||||||
|
this.nnLength.set(this.sim.nnLength);
|
||||||
|
this.bestLength.set(this.sim.bestLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerLeave(): void {
|
||||||
|
this.pressed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSimulation(cities: City[]): void {
|
||||||
|
this.sim = new AntColonySimulation(cities, this.currentSettings());
|
||||||
|
this.cityCount.set(cities.length);
|
||||||
|
this.pending = 0;
|
||||||
|
this.iteration.set(0);
|
||||||
|
this.bestLength.set(this.sim.bestLength);
|
||||||
|
this.nnLength.set(this.sim.nnLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentSettings(): AntColonySettings {
|
||||||
|
return clampSettings({ants: this.ants(), alpha: this.alpha(), beta: this.beta(), evaporation: this.evaporation()});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toWorld(event: PointerEvent): City {
|
||||||
|
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: (event.clientX - rect.left) / rect.width * WORLD_SIZE,
|
||||||
|
y: (event.clientY - rect.top) / rect.height * WORLD_SIZE
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly frame = (now: number): void => {
|
||||||
|
this.fpsCounter.tick(now);
|
||||||
|
const dt = this.lastFrameTime ? Math.min((now - this.lastFrameTime) / 1000, 0.25) : 0;
|
||||||
|
this.lastFrameTime = now;
|
||||||
|
const sim = this.sim;
|
||||||
|
if (sim) {
|
||||||
|
if (this.running()) {
|
||||||
|
this.advance(sim, dt);
|
||||||
|
}
|
||||||
|
this.draw(sim);
|
||||||
|
}
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
private advance(sim: AntColonySimulation, dt: number): void {
|
||||||
|
sim.setSettings(this.currentSettings());
|
||||||
|
const iterations = dt * this.speed();
|
||||||
|
this.pending += iterations;
|
||||||
|
this.phase = (this.phase + iterations) % 1;
|
||||||
|
const start = performance.now();
|
||||||
|
while (this.pending >= 1 && performance.now() - start < STEP_BUDGET_MS) {
|
||||||
|
sim.step();
|
||||||
|
this.pending--;
|
||||||
|
}
|
||||||
|
if (this.pending >= 1) {
|
||||||
|
this.pending = 0;
|
||||||
|
}
|
||||||
|
this.iteration.set(sim.iteration);
|
||||||
|
this.bestLength.set(sim.bestLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw(sim: AntColonySimulation): void {
|
||||||
|
const colors = THEME_COLORS[this.themeService.theme()];
|
||||||
|
const scale = this.cssSize / WORLD_SIZE;
|
||||||
|
this.ctx.clearRect(0, 0, this.cssSize, this.cssSize);
|
||||||
|
this.drawPheromone(sim, colors, scale);
|
||||||
|
this.drawBestTour(sim, colors, scale);
|
||||||
|
this.drawDots(sim.cities, colors.city, 4, scale);
|
||||||
|
const count = sim.antTours.length;
|
||||||
|
const ants = sim.antTours.map((tour, k) => pointOnTour(tour, sim.cities, (this.phase + k / count) % 1));
|
||||||
|
this.drawDots(ants, colors.ant, 3, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawPheromone(sim: AntColonySimulation, colors: AntColonyColors, scale: number): void {
|
||||||
|
if (sim.maxPheromone <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const n = sim.cities.length;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
for (let j = i + 1; j < n; j++) {
|
||||||
|
const ratio = sim.pheromone[i * n + j] / sim.maxPheromone;
|
||||||
|
if (ratio > MIN_DRAW_RATIO) {
|
||||||
|
ctx.strokeStyle = `rgba(${colors.pheromone}, ${ratio})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(sim.cities[i].x * scale, sim.cities[i].y * scale);
|
||||||
|
ctx.lineTo(sim.cities[j].x * scale, sim.cities[j].y * scale);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawBestTour(sim: AntColonySimulation, colors: AntColonyColors, scale: number): void {
|
||||||
|
if (sim.bestTour.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.strokeStyle = colors.best;
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (const index of sim.bestTour) {
|
||||||
|
ctx.lineTo(sim.cities[index].x * scale, sim.cities[index].y * scale);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawDots(points: readonly City[], color: string, radius: number, scale: number): void {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
for (const p of points) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x * scale, p.y * scale, radius, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
CITY_MARGIN, City, cheapestInsertion, clampCityCount, clampSettings, DEFAULT_CITIES, DEFAULT_SETTINGS, DEFAULT_SPEED, MAX_CITIES,
|
||||||
|
MAX_SPEED, MIN_CITIES, MIN_SPEED, nearestNeighborTour, pointOnTour, randomCities, SETTINGS_LIMITS, tourLength, WORLD_SIZE
|
||||||
|
} from './ant-colony.model';
|
||||||
|
|
||||||
|
const square: City[] = [{x: 0, y: 0}, {x: 100, y: 0}, {x: 100, y: 100}, {x: 0, y: 100}];
|
||||||
|
|
||||||
|
function onGrid(value: number, min: number, step: number): boolean {
|
||||||
|
const steps = (value - min) / step;
|
||||||
|
return Math.abs(steps - Math.round(steps)) * step < 1e-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ant colony model', () => {
|
||||||
|
it('tourLength closes the loop', () => {
|
||||||
|
expect(tourLength([0, 1, 2, 3], square)).toBeCloseTo(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tourLength is 0 for one city', () => {
|
||||||
|
expect(tourLength([0], square)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nearestNeighborTour picks the closest unvisited city each step', () => {
|
||||||
|
const cities: City[] = [{x: 0, y: 0}, {x: 90, y: 0}, {x: 10, y: 0}, {x: 50, y: 0}];
|
||||||
|
expect(nearestNeighborTour(cities, 0)).toEqual([0, 2, 3, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nearestNeighborTour visits every city once', () => {
|
||||||
|
const cities = randomCities(25);
|
||||||
|
expect([...nearestNeighborTour(cities, 3)].sort((a, b) => a - b)).toEqual(cities.map((_, i) => i));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cheapestInsertion puts a city on the edge between its neighbors', () => {
|
||||||
|
const cities = [...square, {x: 50, y: 0}];
|
||||||
|
expect(cheapestInsertion([0, 1, 2, 3], cities, 4)).toEqual([0, 4, 1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pointOnTour returns city 0 at 0 and the opposite corner at 0.5', () => {
|
||||||
|
expect(pointOnTour([0, 1, 2, 3], square, 0)).toEqual({x: 0, y: 0});
|
||||||
|
const half = pointOnTour([0, 1, 2, 3], square, 0.5);
|
||||||
|
expect(half.x).toBeCloseTo(100);
|
||||||
|
expect(half.y).toBeCloseTo(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('randomCities stays inside the margins', () => {
|
||||||
|
for (const value of [0, 0.999]) {
|
||||||
|
for (const c of randomCities(5, () => value)) {
|
||||||
|
expect(c.x).toBeGreaterThanOrEqual(CITY_MARGIN);
|
||||||
|
expect(c.x).toBeLessThanOrEqual(WORLD_SIZE - CITY_MARGIN);
|
||||||
|
expect(c.y).toBeGreaterThanOrEqual(CITY_MARGIN);
|
||||||
|
expect(c.y).toBeLessThanOrEqual(WORLD_SIZE - CITY_MARGIN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps slider defaults and maxima on the step grid', () => {
|
||||||
|
for (const key of Object.keys(SETTINGS_LIMITS) as (keyof typeof SETTINGS_LIMITS)[]) {
|
||||||
|
const {min, max, step} = SETTINGS_LIMITS[key];
|
||||||
|
expect(onGrid(DEFAULT_SETTINGS[key], min, step), `${key} default`).toBe(true);
|
||||||
|
expect(onGrid(max, min, step), `${key} max`).toBe(true);
|
||||||
|
}
|
||||||
|
expect(onGrid(DEFAULT_CITIES, MIN_CITIES, 1)).toBe(true);
|
||||||
|
expect(onGrid(MAX_CITIES, MIN_CITIES, 1)).toBe(true);
|
||||||
|
expect(onGrid(DEFAULT_SPEED, MIN_SPEED, 1)).toBe(true);
|
||||||
|
expect(onGrid(MAX_SPEED, MIN_SPEED, 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clampSettings limits every value', () => {
|
||||||
|
expect(clampSettings({ants: 1000, alpha: -1, beta: 99, evaporation: 0})).toEqual({
|
||||||
|
ants: SETTINGS_LIMITS.ants.max, alpha: SETTINGS_LIMITS.alpha.min, beta: SETTINGS_LIMITS.beta.max, evaporation: SETTINGS_LIMITS.evaporation.min
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clampCityCount handles NaN, low and high', () => {
|
||||||
|
expect(clampCityCount(NaN)).toBe(DEFAULT_CITIES);
|
||||||
|
expect(clampCityCount(1)).toBe(MIN_CITIES);
|
||||||
|
expect(clampCityCount(9999)).toBe(MAX_CITIES);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
export interface City {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AntColonySettings {
|
||||||
|
ants: number;
|
||||||
|
/** Weight of the pheromone in the choice of the next city. */
|
||||||
|
alpha: number;
|
||||||
|
/** Weight of the closeness in the choice of the next city. */
|
||||||
|
beta: number;
|
||||||
|
/** Share of the pheromone that vanishes each iteration. */
|
||||||
|
evaporation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AntColonyColors {
|
||||||
|
city: string;
|
||||||
|
/** rgb triple, the alpha is set per edge. */
|
||||||
|
pheromone: string;
|
||||||
|
best: string;
|
||||||
|
ant: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cities and tour lengths live in these world units, not in pixels. */
|
||||||
|
export const WORLD_SIZE = 1000;
|
||||||
|
export const CITY_MARGIN = 40;
|
||||||
|
export const MIN_CITIES = 3;
|
||||||
|
export const MAX_CITIES = 120;
|
||||||
|
export const DEFAULT_CITIES = 30;
|
||||||
|
/** Duplicate click guard, world units. */
|
||||||
|
export const MIN_CITY_DISTANCE = 15;
|
||||||
|
/** Iterations per second. */
|
||||||
|
export const MIN_SPEED = 1;
|
||||||
|
export const MAX_SPEED = 30;
|
||||||
|
export const DEFAULT_SPEED = 5;
|
||||||
|
export const MAX_CANVAS_PX = 800;
|
||||||
|
/** Per frame, so max cities and max ants drop iterations instead of freezing the page. */
|
||||||
|
export const STEP_BUDGET_MS = 8;
|
||||||
|
/** Floor after evaporation, keeps the probabilities finite. */
|
||||||
|
export const TAU_MIN = 1e-6;
|
||||||
|
/** Deposit of an ant is Q / tour length. */
|
||||||
|
export const PHEROMONE_Q = 1;
|
||||||
|
/** Edges with a weaker share of the strongest trail are not drawn. */
|
||||||
|
export const MIN_DRAW_RATIO = 0.02;
|
||||||
|
export const CLICK_MOVE_PX = 8;
|
||||||
|
|
||||||
|
export const SETTINGS_LIMITS: Record<keyof AntColonySettings, {min: number; max: number; step: number}> = {
|
||||||
|
ants: {min: 1, max: 64, step: 1},
|
||||||
|
alpha: {min: 0, max: 5, step: 0.1},
|
||||||
|
beta: {min: 0, max: 10, step: 0.5},
|
||||||
|
evaporation: {min: 0.01, max: 0.5, step: 0.01}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: AntColonySettings = {ants: 20, alpha: 1, beta: 3, evaporation: 0.1};
|
||||||
|
|
||||||
|
export const THEME_COLORS: Record<'light' | 'dark', AntColonyColors> = {
|
||||||
|
light: {city: '#222', pheromone: '15, 79, 138', best: '#d32f2f', ant: '#2e7d32'},
|
||||||
|
dark: {city: '#eee', pheromone: '109, 188, 255', best: '#ff7961', ant: '#81c784'}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function clampSettings(settings: AntColonySettings): AntColonySettings {
|
||||||
|
const clamped = {...settings};
|
||||||
|
for (const key of Object.keys(SETTINGS_LIMITS) as (keyof AntColonySettings)[]) {
|
||||||
|
const {min, max} = SETTINGS_LIMITS[key];
|
||||||
|
clamped[key] = Math.min(max, Math.max(min, settings[key]));
|
||||||
|
}
|
||||||
|
clamped.ants = Math.round(clamped.ants);
|
||||||
|
return clamped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampCityCount(count: number): number {
|
||||||
|
if (!Number.isFinite(count)) {
|
||||||
|
return DEFAULT_CITIES;
|
||||||
|
}
|
||||||
|
return Math.min(MAX_CITIES, Math.max(MIN_CITIES, Math.round(count)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomCities(count: number, random: () => number = Math.random): City[] {
|
||||||
|
const span = WORLD_SIZE - 2 * CITY_MARGIN;
|
||||||
|
return Array.from({length: count}, () => ({x: CITY_MARGIN + random() * span, y: CITY_MARGIN + random() * span}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distance(a: City, b: City): number {
|
||||||
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closed loop. */
|
||||||
|
export function tourLength(tour: readonly number[], cities: readonly City[]): number {
|
||||||
|
if (tour.length < 2) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let length = 0;
|
||||||
|
for (let i = 0; i < tour.length; i++) {
|
||||||
|
length += distance(cities[tour[i]], cities[tour[(i + 1) % tour.length]]);
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nearestNeighborTour(cities: readonly City[], start = 0): number[] {
|
||||||
|
if (cities.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const visited = new Set<number>([start]);
|
||||||
|
const tour = [start];
|
||||||
|
while (tour.length < cities.length) {
|
||||||
|
const last = cities[tour[tour.length - 1]];
|
||||||
|
let best = -1;
|
||||||
|
let bestDistance = Infinity;
|
||||||
|
for (let i = 0; i < cities.length; i++) {
|
||||||
|
if (!visited.has(i)) {
|
||||||
|
const d = distance(last, cities[i]);
|
||||||
|
if (d < bestDistance) {
|
||||||
|
bestDistance = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visited.add(best);
|
||||||
|
tour.push(best);
|
||||||
|
}
|
||||||
|
return tour;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New tour with city `index` at the cheapest position. */
|
||||||
|
export function cheapestInsertion(tour: readonly number[], cities: readonly City[], index: number): number[] {
|
||||||
|
if (tour.length < 2) {
|
||||||
|
return [...tour, index];
|
||||||
|
}
|
||||||
|
const city = cities[index];
|
||||||
|
let bestPosition = 1;
|
||||||
|
let bestCost = Infinity;
|
||||||
|
for (let i = 0; i < tour.length; i++) {
|
||||||
|
const a = cities[tour[i]];
|
||||||
|
const b = cities[tour[(i + 1) % tour.length]];
|
||||||
|
const cost = distance(a, city) + distance(city, b) - distance(a, b);
|
||||||
|
if (cost < bestCost) {
|
||||||
|
bestCost = cost;
|
||||||
|
bestPosition = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = [...tour];
|
||||||
|
result.splice(bestPosition, 0, index);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point at `fraction` (0..1) of the closed polyline. */
|
||||||
|
export function pointOnTour(tour: readonly number[], cities: readonly City[], fraction: number): City {
|
||||||
|
if (tour.length === 0) {
|
||||||
|
return {x: 0, y: 0};
|
||||||
|
}
|
||||||
|
let remaining = (((fraction % 1) + 1) % 1) * tourLength(tour, cities);
|
||||||
|
for (let i = 0; i < tour.length; i++) {
|
||||||
|
const a = cities[tour[i]];
|
||||||
|
const b = cities[tour[(i + 1) % tour.length]];
|
||||||
|
const d = distance(a, b);
|
||||||
|
if (remaining <= d) {
|
||||||
|
const t = d === 0 ? 0 : remaining / d;
|
||||||
|
return {x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t};
|
||||||
|
}
|
||||||
|
remaining -= d;
|
||||||
|
}
|
||||||
|
return cities[tour[0]];
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* GLSL shaders for agent rendering on WebGL.
|
||||||
|
* The CPU path computes the color per vertex (agentColor in boids-cpu-physics.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const AGENT_VERTEX_SHADER_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
attribute vec3 position;
|
||||||
|
attribute vec3 agentColor;
|
||||||
|
|
||||||
|
uniform mat4 viewProjection;
|
||||||
|
|
||||||
|
varying vec3 vColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vColor = agentColor;
|
||||||
|
gl_Position = viewProjection * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const AGENT_FRAGMENT_SHADER_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
varying vec3 vColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = vec4(vColor, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'BOIDS.TITLE' | translate }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
|
<div class="flex flex-col mb-4">
|
||||||
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
|
<mat-button-toggle-group class="rounded-sm overflow-hidden" [value]="mode()" (change)="setMode($event.value)" aria-label="Mode">
|
||||||
|
<mat-button-toggle value="boids">{{ 'BOIDS.MODE_BOIDS' | translate }}</mat-button-toggle>
|
||||||
|
<mat-button-toggle value="particleLife">{{ 'BOIDS.MODE_PARTICLE_LIFE' | translate }}</mat-button-toggle>
|
||||||
|
</mat-button-toggle-group>
|
||||||
|
<button mat-raised-button color="accent" (click)="restartSimulation()">
|
||||||
|
{{ 'BOIDS.RESTART' | translate }}
|
||||||
|
</button>
|
||||||
|
@if (mode() === 'particleLife') {
|
||||||
|
<button mat-raised-button color="primary" (click)="rerollMatrix()">
|
||||||
|
{{ 'BOIDS.REROLL_MATRIX' | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<app-slider-field label="BOIDS.AGENTS" [min]="minAgents" [max]="maxAgents()" [step]="16" [(value)]="agentCount" (committed)="restartSimulation()"/>
|
||||||
|
<app-slider-field label="BOIDS.MAX_SPEED" [min]="boidsLimits.maxSpeedMin" [max]="boidsLimits.maxSpeedMax" [(value)]="maxSpeed"/>
|
||||||
|
@if (mode() === 'boids') {
|
||||||
|
<app-slider-field label="BOIDS.SEPARATION" [min]="0" [max]="boidsLimits.weightMax" [step]="0.1" [(value)]="separation"/>
|
||||||
|
<app-slider-field label="BOIDS.ALIGNMENT" [min]="0" [max]="boidsLimits.weightMax" [step]="0.1" [(value)]="alignment"/>
|
||||||
|
<app-slider-field label="BOIDS.COHESION" [min]="0" [max]="boidsLimits.weightMax" [step]="0.1" [(value)]="cohesion"/>
|
||||||
|
<app-slider-field label="BOIDS.VIEW_RADIUS" [min]="boidsLimits.viewRadiusMin" [max]="boidsLimits.viewRadiusMax" [step]="0.5" [(value)]="viewRadius"/>
|
||||||
|
<p class="text-[0.9em] opacity-80">{{ 'BOIDS.PREDATOR_HINT' | translate }}</p>
|
||||||
|
} @else {
|
||||||
|
<app-slider-field label="BOIDS.TYPES" [min]="lifeLimits.numTypesMin" [max]="lifeLimits.numTypesMax" [(value)]="numTypes" (committed)="onNumTypesChange()"/>
|
||||||
|
<app-slider-field label="BOIDS.INTERACTION_RADIUS" [min]="2" [max]="20" [step]="0.5" [(value)]="interactionRadius"/>
|
||||||
|
<app-slider-field label="BOIDS.FRICTION" [min]="0.5" [max]="0.99" [step]="0.01" [(value)]="friction"/>
|
||||||
|
<app-slider-field label="BOIDS.FORCE_SCALE" [min]="10" [max]="200" [step]="5" [(value)]="forceScale"/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
|
<app-babylon-canvas
|
||||||
|
[config]="renderConfig"
|
||||||
|
(sceneReady)="onSceneReady($event)"
|
||||||
|
(sceneResized)="onSceneResized($event)"
|
||||||
|
/>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
Whitespace-only changes.
@@ -0,0 +1,234 @@
|
|||||||
|
import {Component, computed, OnDestroy, signal} from '@angular/core';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
|
||||||
|
import {MatButtonToggleModule} from '@angular/material/button-toggle';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
import {Engine, PointerEventTypes, Scene, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {SOURCE_PATHS} from '../information/source-paths';
|
||||||
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
|
import {
|
||||||
|
BOIDS_DEFAULTS,
|
||||||
|
BOIDS_LIMITS,
|
||||||
|
clampAgentCount,
|
||||||
|
defaultAgentsFor,
|
||||||
|
DEFAULT_AGENTS_GPU,
|
||||||
|
LIFE_DEFAULTS,
|
||||||
|
LIFE_LIMITS,
|
||||||
|
MAX_AGENTS_GPU,
|
||||||
|
maxAgentsFor,
|
||||||
|
MIN_AGENTS,
|
||||||
|
pointerToWorld,
|
||||||
|
randomInteractionMatrix,
|
||||||
|
SwarmConfig,
|
||||||
|
SwarmMode,
|
||||||
|
WORLD_SIZE
|
||||||
|
} from './boids.model';
|
||||||
|
import {SwarmSimulationParams, SwarmSimulationStrategy} from './strategies/boids-simulation.strategy';
|
||||||
|
import {BoidsGpuStrategy} from './strategies/boids-gpu.strategy';
|
||||||
|
import {BoidsCpuStrategy} from './strategies/boids-cpu.strategy';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-boids',
|
||||||
|
imports: [
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
TranslatePipe,
|
||||||
|
BabylonCanvas,
|
||||||
|
MatButton,
|
||||||
|
MatButtonToggleModule,
|
||||||
|
SliderFieldComponent,
|
||||||
|
Information,
|
||||||
|
MetricsComponent
|
||||||
|
],
|
||||||
|
templateUrl: './boids.component.html',
|
||||||
|
styleUrl: './boids.component.scss',
|
||||||
|
})
|
||||||
|
export class BoidsComponent implements OnDestroy {
|
||||||
|
private currentSceneData: SceneEventData | null = null;
|
||||||
|
private strategy: SwarmSimulationStrategy | null = null;
|
||||||
|
private matrix = randomInteractionMatrix(LIFE_DEFAULTS.numTypes);
|
||||||
|
private readonly predator = {x: 0, y: 0, active: false};
|
||||||
|
private canvas: HTMLCanvasElement | null = null;
|
||||||
|
private pointerAttached = false;
|
||||||
|
private agentCountInitialized = false;
|
||||||
|
private readonly pointerLeaveHandler = () => { this.predator.active = false; };
|
||||||
|
private readonly fpsCounter = new FpsCounter();
|
||||||
|
|
||||||
|
readonly minAgents = MIN_AGENTS;
|
||||||
|
readonly boidsLimits = BOIDS_LIMITS;
|
||||||
|
readonly lifeLimits = LIFE_LIMITS;
|
||||||
|
|
||||||
|
readonly mode = signal<SwarmMode>('boids');
|
||||||
|
readonly agentCount = signal(DEFAULT_AGENTS_GPU);
|
||||||
|
readonly maxAgents = signal(MAX_AGENTS_GPU);
|
||||||
|
readonly numTypes = signal(LIFE_DEFAULTS.numTypes);
|
||||||
|
readonly separation = signal(BOIDS_DEFAULTS.separation);
|
||||||
|
readonly alignment = signal(BOIDS_DEFAULTS.alignment);
|
||||||
|
readonly cohesion = signal(BOIDS_DEFAULTS.cohesion);
|
||||||
|
readonly viewRadius = signal(BOIDS_DEFAULTS.viewRadius);
|
||||||
|
readonly maxSpeed = signal(BOIDS_DEFAULTS.maxSpeed);
|
||||||
|
readonly interactionRadius = signal(LIFE_DEFAULTS.interactionRadius);
|
||||||
|
readonly friction = signal(LIFE_DEFAULTS.friction);
|
||||||
|
readonly forceScale = signal(LIFE_DEFAULTS.forceScale);
|
||||||
|
readonly backend = signal('–');
|
||||||
|
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
|
||||||
|
{label: 'METRICS.BACKEND', value: this.backend()},
|
||||||
|
{label: 'METRICS.AGENTS', value: this.agentCount()},
|
||||||
|
{label: 'METRICS.PAIR_CHECKS', value: this.agentCount() ** 2, fractionDigits: 0}
|
||||||
|
]);
|
||||||
|
|
||||||
|
readonly renderConfig: RenderConfig = {
|
||||||
|
mode: '2D',
|
||||||
|
initialViewSize: WORLD_SIZE
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'BOIDS.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'BOIDS.EXPLANATION.BOIDS_TITLE',
|
||||||
|
description: 'BOIDS.EXPLANATION.BOIDS',
|
||||||
|
link: UrlConstants.BOIDS_WIKI,
|
||||||
|
translateName: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'BOIDS.EXPLANATION.PARTICLE_LIFE_TITLE',
|
||||||
|
description: 'BOIDS.EXPLANATION.PARTICLE_LIFE',
|
||||||
|
link: UrlConstants.PARTICLE_LIFE_WIKI,
|
||||||
|
linkLabel: 'particle-life.com',
|
||||||
|
translateName: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'BOIDS.EXPLANATION.GPU_TITLE',
|
||||||
|
description: 'BOIDS.EXPLANATION.GPU',
|
||||||
|
link: UrlConstants.GPU_COMPUTING_WIKI,
|
||||||
|
translateName: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'BOIDS.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: '',
|
||||||
|
disclaimerListEntry: ['BOIDS.EXPLANATION.DISCLAIMER_1', 'BOIDS.EXPLANATION.DISCLAIMER_2', 'BOIDS.EXPLANATION.DISCLAIMER_3'],
|
||||||
|
howItWorks: {
|
||||||
|
intro: 'BOIDS.HOW_IT_WORKS.INTRO',
|
||||||
|
items: ['BOIDS.HOW_IT_WORKS.ITEM_1', 'BOIDS.HOW_IT_WORKS.ITEM_2', 'BOIDS.HOW_IT_WORKS.ITEM_3', 'BOIDS.HOW_IT_WORKS.ITEM_4', 'BOIDS.HOW_IT_WORKS.ITEM_5']
|
||||||
|
},
|
||||||
|
sources: SOURCE_PATHS.BOIDS
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.canvas?.removeEventListener('pointerleave', this.pointerLeaveHandler);
|
||||||
|
this.strategy?.dispose();
|
||||||
|
this.strategy = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSceneReady(event: SceneEventData): void {
|
||||||
|
this.currentSceneData = event;
|
||||||
|
this.maxAgents.set(maxAgentsFor(event.gpuTier));
|
||||||
|
const wanted = this.agentCountInitialized ? this.agentCount() : defaultAgentsFor(event.gpuTier);
|
||||||
|
this.agentCountInitialized = true;
|
||||||
|
this.agentCount.set(clampAgentCount(wanted, event.gpuTier));
|
||||||
|
this.backend.set(event.gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
|
||||||
|
this.createSimulation();
|
||||||
|
this.attachPointer(event.scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resize keeps the running simulation. Only a changed GPU tier needs a new strategy. */
|
||||||
|
onSceneResized(event: SceneEventData): void {
|
||||||
|
if (event.gpuTier !== this.currentSceneData?.gpuTier) {
|
||||||
|
this.onSceneReady(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(mode: SwarmMode): void {
|
||||||
|
this.mode.set(mode);
|
||||||
|
this.restartSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
restartSimulation(): void {
|
||||||
|
this.createSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
onNumTypesChange(): void {
|
||||||
|
this.matrix = randomInteractionMatrix(this.numTypes());
|
||||||
|
this.restartSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
rerollMatrix(): void {
|
||||||
|
this.matrix = randomInteractionMatrix(this.numTypes());
|
||||||
|
this.strategy?.setInteractionMatrix(this.matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSimulation(): void {
|
||||||
|
if (!this.currentSceneData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {engine, scene, gpuTier} = this.currentSceneData;
|
||||||
|
this.strategy?.dispose();
|
||||||
|
|
||||||
|
const isBoids = this.mode() === 'boids';
|
||||||
|
const config: SwarmConfig = {
|
||||||
|
mode: this.mode(),
|
||||||
|
count: this.agentCount(),
|
||||||
|
numTypes: isBoids ? 1 : this.numTypes(),
|
||||||
|
matrix: this.matrix
|
||||||
|
};
|
||||||
|
|
||||||
|
this.strategy = gpuTier === 'webgpu' ? new BoidsGpuStrategy() : new BoidsCpuStrategy();
|
||||||
|
this.strategy.init(scene, engine, config, () => this.currentParams(engine));
|
||||||
|
this.startFrameLoop(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentParams(engine: WebGPUEngine | Engine): SwarmSimulationParams {
|
||||||
|
return {
|
||||||
|
deltaTime: engine.getDeltaTime() / 1000,
|
||||||
|
separation: this.separation(),
|
||||||
|
alignment: this.alignment(),
|
||||||
|
cohesion: this.cohesion(),
|
||||||
|
viewRadius: this.viewRadius(),
|
||||||
|
maxSpeed: this.maxSpeed(),
|
||||||
|
predator: this.predator,
|
||||||
|
interactionRadius: this.interactionRadius(),
|
||||||
|
friction: this.friction(),
|
||||||
|
forceScale: this.forceScale()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private startFrameLoop(scene: Scene): void {
|
||||||
|
scene.onAfterRenderObservable.clear();
|
||||||
|
scene.onAfterRenderObservable.add(() => this.fpsCounter.tick());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */
|
||||||
|
private attachPointer(scene: Scene): void {
|
||||||
|
if (this.pointerAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pointerAttached = true;
|
||||||
|
|
||||||
|
this.canvas = scene.getEngine().getRenderingCanvas();
|
||||||
|
this.canvas?.addEventListener('pointerleave', this.pointerLeaveHandler);
|
||||||
|
|
||||||
|
scene.onPointerObservable.add((info) => {
|
||||||
|
if (info.type === PointerEventTypes.POINTERUP && (info.event as PointerEvent).pointerType === 'touch') {
|
||||||
|
this.predator.active = false;
|
||||||
|
} else if (info.type === PointerEventTypes.POINTERMOVE || info.type === PointerEventTypes.POINTERDOWN) {
|
||||||
|
const target = info.event.target as HTMLElement;
|
||||||
|
const world = pointerToWorld(info.event.offsetX, info.event.offsetY, target.clientWidth, target.clientHeight);
|
||||||
|
this.predator.x = world.x;
|
||||||
|
this.predator.y = world.y;
|
||||||
|
this.predator.active = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
clampAgentCount, DEFAULT_AGENTS_CPU, DEFAULT_AGENTS_GPU, MAX_AGENTS_CPU, MAX_AGENTS_GPU, MAX_TYPES, MIN_AGENTS, BOIDS_DEFAULTS,
|
||||||
|
pointerToWorld, randomInteractionMatrix, seedAgents, SwarmConfig, WORLD_SIZE
|
||||||
|
} from './boids.model';
|
||||||
|
|
||||||
|
function sequence(...values: number[]): () => number {
|
||||||
|
let i = 0;
|
||||||
|
return () => values[i++ % values.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function config(overrides: Partial<SwarmConfig>): SwarmConfig {
|
||||||
|
return {mode: 'boids', count: 50, numTypes: 1, matrix: new Float32Array(MAX_TYPES * MAX_TYPES), ...overrides};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('boids model', () => {
|
||||||
|
describe('clampAgentCount', () => {
|
||||||
|
it('caps at the GPU maximum on webgpu', () => {
|
||||||
|
expect(clampAgentCount(100000, 'webgpu')).toBe(MAX_AGENTS_GPU);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at the CPU maximum on webgl', () => {
|
||||||
|
expect(clampAgentCount(5000, 'webgl')).toBe(MAX_AGENTS_CPU);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the default of the tier for NaN', () => {
|
||||||
|
expect(clampAgentCount(NaN, 'webgpu')).toBe(DEFAULT_AGENTS_GPU);
|
||||||
|
expect(clampAgentCount(NaN, 'webgl')).toBe(DEFAULT_AGENTS_CPU);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lifts values below the minimum', () => {
|
||||||
|
expect(clampAgentCount(1, 'webgpu')).toBe(MIN_AGENTS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('randomInteractionMatrix', () => {
|
||||||
|
it('fills only the numTypes block with values in [-1, 1]', () => {
|
||||||
|
const matrix = randomInteractionMatrix(3, sequence(0, 0.5, 1));
|
||||||
|
|
||||||
|
expect(matrix.length).toBe(MAX_TYPES * MAX_TYPES);
|
||||||
|
for (let a = 0; a < MAX_TYPES; a++) {
|
||||||
|
for (let b = 0; b < MAX_TYPES; b++) {
|
||||||
|
const value = matrix[a * MAX_TYPES + b];
|
||||||
|
if (a < 3 && b < 3) {
|
||||||
|
expect(value).toBeGreaterThanOrEqual(-1);
|
||||||
|
expect(value).toBeLessThanOrEqual(1);
|
||||||
|
} else {
|
||||||
|
expect(value).toBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(matrix[0]).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seedAgents', () => {
|
||||||
|
it('places all agents inside the world with a valid type', () => {
|
||||||
|
const data = seedAgents(config({mode: 'particleLife', numTypes: 4}), sequence(0, 0.3, 0.6, 0.99, 1));
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
expect(Math.abs(data.positions[i * 4])).toBeLessThanOrEqual(WORLD_SIZE / 2);
|
||||||
|
expect(Math.abs(data.positions[i * 4 + 1])).toBeLessThanOrEqual(WORLD_SIZE / 2);
|
||||||
|
expect(data.positions[i * 4 + 2]).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(data.positions[i * 4 + 2]).toBeLessThan(4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts boids at half the default max speed', () => {
|
||||||
|
const data = seedAgents(config({mode: 'boids'}), sequence(0.2, 0.7, 0.4, 0));
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
expect(Math.hypot(data.velocities[i * 4], data.velocities[i * 4 + 1])).toBeCloseTo(BOIDS_DEFAULTS.maxSpeed * 0.5, 4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts particle life agents at rest', () => {
|
||||||
|
const data = seedAgents(config({mode: 'particleLife', numTypes: 3}));
|
||||||
|
|
||||||
|
expect(data.velocities.every(v => v === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pointerToWorld', () => {
|
||||||
|
it('maps the canvas center to the origin', () => {
|
||||||
|
expect(pointerToWorld(200, 200, 400, 400)).toEqual({x: 0, y: 0});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the top-left corner to (-50, 50)', () => {
|
||||||
|
expect(pointerToWorld(0, 0, 400, 400)).toEqual({x: -50, y: 50});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {GpuTier} from '../../../service/gpu-capability.service';
|
||||||
|
|
||||||
|
export type SwarmMode = 'boids' | 'particleLife';
|
||||||
|
|
||||||
|
export const WORLD_SIZE = 100;
|
||||||
|
export const AGENT_SIZE = 0.8;
|
||||||
|
export const MAX_TYPES = 8;
|
||||||
|
export const PARAMS_LENGTH = 20;
|
||||||
|
export const WORKGROUP_SIZE = 64;
|
||||||
|
|
||||||
|
export const MAX_AGENTS_GPU = 8192;
|
||||||
|
export const MAX_AGENTS_CPU = 1200;
|
||||||
|
export const DEFAULT_AGENTS_GPU = 2048;
|
||||||
|
export const DEFAULT_AGENTS_CPU = 400;
|
||||||
|
export const MIN_AGENTS = 16;
|
||||||
|
|
||||||
|
export const BOIDS_DEFAULTS = {separation: 1.5, alignment: 1.0, cohesion: 1.0, viewRadius: 6, maxSpeed: 20};
|
||||||
|
export const BOIDS_LIMITS = {weightMax: 3, viewRadiusMin: 2, viewRadiusMax: 15, maxSpeedMin: 5, maxSpeedMax: 40};
|
||||||
|
/** Boids never stop: the speed floor is this ratio of the max speed. */
|
||||||
|
export const MIN_SPEED_RATIO = 0.25;
|
||||||
|
export const PREDATOR_RADIUS = 12;
|
||||||
|
/** Acceleration at the predator. Falls off linearly to 0 at PREDATOR_RADIUS. */
|
||||||
|
export const PREDATOR_FORCE = 400;
|
||||||
|
|
||||||
|
export const LIFE_DEFAULTS = {numTypes: 5, interactionRadius: 8, friction: 0.9, forceScale: 60};
|
||||||
|
export const LIFE_LIMITS = {numTypesMin: 2, numTypesMax: MAX_TYPES};
|
||||||
|
/** The repulsion zone is beta * interactionRadius. */
|
||||||
|
export const LIFE_BETA = 0.3;
|
||||||
|
|
||||||
|
/** Index into the Params float array. Same order as `struct Params` in boids.shader.ts. */
|
||||||
|
export enum ParamIndex {
|
||||||
|
dt = 0, count = 1, worldWidth = 2, worldHeight = 3,
|
||||||
|
viewRadius = 4, maxSpeed = 5, separation = 6, alignment = 7, cohesion = 8,
|
||||||
|
predatorX = 9, predatorY = 10, predatorRadius = 11, predatorActive = 12,
|
||||||
|
numTypes = 13, interactionRadius = 14, friction = 15, forceScale = 16,
|
||||||
|
predatorForce = 17, agentSize = 18, minSpeedRatio = 19
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmConfig {
|
||||||
|
mode: SwarmMode;
|
||||||
|
count: number;
|
||||||
|
/** 1 in boids mode. */
|
||||||
|
numTypes: number;
|
||||||
|
/** MAX_TYPES * MAX_TYPES, row-major [a * MAX_TYPES + b]. */
|
||||||
|
matrix: Float32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmData {
|
||||||
|
/** count * 4: [x, y, type, 0]. */
|
||||||
|
positions: Float32Array;
|
||||||
|
/** count * 4: [vx, vy, 0, 0]. */
|
||||||
|
velocities: Float32Array;
|
||||||
|
params: Float32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maxAgentsFor(tier: GpuTier): number {
|
||||||
|
return tier === 'webgpu' ? MAX_AGENTS_GPU : MAX_AGENTS_CPU;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultAgentsFor(tier: GpuTier): number {
|
||||||
|
return tier === 'webgpu' ? DEFAULT_AGENTS_GPU : DEFAULT_AGENTS_CPU;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampAgentCount(count: number, tier: GpuTier): number {
|
||||||
|
if (!Number.isFinite(count)) {
|
||||||
|
return defaultAgentsFor(tier);
|
||||||
|
}
|
||||||
|
return Math.min(maxAgentsFor(tier), Math.max(MIN_AGENTS, Math.round(count)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomInteractionMatrix(numTypes: number, random: () => number = Math.random): Float32Array {
|
||||||
|
const matrix = new Float32Array(MAX_TYPES * MAX_TYPES);
|
||||||
|
for (let a = 0; a < numTypes; a++) {
|
||||||
|
for (let b = 0; b < numTypes; b++) {
|
||||||
|
matrix[a * MAX_TYPES + b] = random() * 2 - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seedAgents(config: SwarmConfig, random: () => number = Math.random): SwarmData {
|
||||||
|
const positions = new Float32Array(config.count * 4);
|
||||||
|
const velocities = new Float32Array(config.count * 4);
|
||||||
|
const speed = config.mode === 'boids' ? BOIDS_DEFAULTS.maxSpeed * 0.5 : 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < config.count; i++) {
|
||||||
|
positions[i * 4] = (random() - 0.5) * WORLD_SIZE;
|
||||||
|
positions[i * 4 + 1] = (random() - 0.5) * WORLD_SIZE;
|
||||||
|
const heading = random() * 2 * Math.PI;
|
||||||
|
positions[i * 4 + 2] = Math.min(config.numTypes - 1, Math.floor(random() * config.numTypes));
|
||||||
|
velocities[i * 4] = Math.cos(heading) * speed;
|
||||||
|
velocities[i * 4 + 1] = Math.sin(heading) * speed;
|
||||||
|
}
|
||||||
|
return {positions, velocities, params: new Float32Array(PARAMS_LENGTH)};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointerToWorld(px: number, py: number, width: number, height: number): {x: number; y: number} {
|
||||||
|
return {x: (px / width - 0.5) * WORLD_SIZE, y: (0.5 - py / height) * WORLD_SIZE};
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* File: boids.shader.ts
|
||||||
|
* Description: WGSL shaders for the boids / particle life simulation and the agent rendering.
|
||||||
|
* The compute kernels mirror boids-cpu-physics.ts 1:1.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Same order as ParamIndex in boids.model.ts.
|
||||||
|
export const SWARM_SHARED_STRUCTS = `
|
||||||
|
struct Params {
|
||||||
|
dt: f32,
|
||||||
|
count: f32,
|
||||||
|
worldWidth: f32,
|
||||||
|
worldHeight: f32,
|
||||||
|
viewRadius: f32,
|
||||||
|
maxSpeed: f32,
|
||||||
|
separation: f32,
|
||||||
|
alignment: f32,
|
||||||
|
cohesion: f32,
|
||||||
|
predatorX: f32,
|
||||||
|
predatorY: f32,
|
||||||
|
predatorRadius: f32,
|
||||||
|
predatorActive: f32,
|
||||||
|
numTypes: f32,
|
||||||
|
interactionRadius: f32,
|
||||||
|
friction: f32,
|
||||||
|
forceScale: f32,
|
||||||
|
predatorForce: f32,
|
||||||
|
agentSize: f32,
|
||||||
|
minSpeedRatio: f32
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SWARM_BINDINGS = `
|
||||||
|
@group(0) @binding(0) var<storage, read> p : Params;
|
||||||
|
@group(0) @binding(1) var<storage, read_write> positions : array<vec4<f32>>;
|
||||||
|
@group(0) @binding(2) var<storage, read_write> velocities : array<vec4<f32>>;
|
||||||
|
@group(0) @binding(3) var<storage, read_write> prev_positions : array<vec4<f32>>;
|
||||||
|
@group(0) @binding(4) var<storage, read_write> prev_velocities : array<vec4<f32>>;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SWARM_HELPERS = `
|
||||||
|
fn wrapped_delta(a : vec2<f32>, b : vec2<f32>) -> vec2<f32> {
|
||||||
|
let size = vec2<f32>(p.worldWidth, p.worldHeight);
|
||||||
|
let d = b - a;
|
||||||
|
return d - size * round(d / size);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrap_position(v : vec2<f32>) -> vec2<f32> {
|
||||||
|
let size = vec2<f32>(p.worldWidth, p.worldHeight);
|
||||||
|
let shifted = v + size * 0.5;
|
||||||
|
return shifted - size * floor(shifted / size) - size * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_speed(v : vec2<f32>, maxSpeed : f32, minSpeed : f32) -> vec2<f32> {
|
||||||
|
let speed = length(v);
|
||||||
|
if (speed < 0.000001) { return v; }
|
||||||
|
if (speed > maxSpeed) { return v / speed * maxSpeed; }
|
||||||
|
if (speed < minSpeed) { return v / speed * minSpeed; }
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Pass 1: copy current state to prev, so the step pass reads a stable snapshot.
|
||||||
|
export const SWARM_COPY_COMPUTE_WGSL = SWARM_SHARED_STRUCTS + SWARM_BINDINGS + `
|
||||||
|
@compute @workgroup_size(64)
|
||||||
|
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
|
||||||
|
let idx = global_id.x;
|
||||||
|
if (idx >= u32(p.count)) { return; }
|
||||||
|
|
||||||
|
prev_positions[idx] = positions[idx];
|
||||||
|
prev_velocities[idx] = velocities[idx];
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Pass 2 (boids): separation, alignment, cohesion, predator.
|
||||||
|
export const BOIDS_STEP_COMPUTE_WGSL = SWARM_SHARED_STRUCTS + SWARM_BINDINGS + SWARM_HELPERS + `
|
||||||
|
@compute @workgroup_size(64)
|
||||||
|
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
|
||||||
|
let idx = global_id.x;
|
||||||
|
let count = u32(p.count);
|
||||||
|
if (idx >= count) { return; }
|
||||||
|
|
||||||
|
let pos = prev_positions[idx].xy;
|
||||||
|
let vel = prev_velocities[idx].xy;
|
||||||
|
|
||||||
|
var sep = vec2<f32>(0.0);
|
||||||
|
var alignSum = vec2<f32>(0.0);
|
||||||
|
var coh = vec2<f32>(0.0);
|
||||||
|
var n = 0.0;
|
||||||
|
|
||||||
|
for (var j = 0u; j < count; j++) {
|
||||||
|
if (j == idx) { continue; }
|
||||||
|
let d = wrapped_delta(pos, prev_positions[j].xy);
|
||||||
|
let r = length(d);
|
||||||
|
if (r >= p.viewRadius || r < 0.000001) { continue; }
|
||||||
|
if (r < p.viewRadius * 0.5) {
|
||||||
|
sep = sep - d / (r * r);
|
||||||
|
}
|
||||||
|
alignSum = alignSum + prev_velocities[j].xy;
|
||||||
|
coh = coh + d;
|
||||||
|
n = n + 1.0;
|
||||||
|
}
|
||||||
|
if (n > 0.0) {
|
||||||
|
alignSum = alignSum / n - vel;
|
||||||
|
coh = coh / n;
|
||||||
|
}
|
||||||
|
|
||||||
|
var acc = sep * p.separation * 10.0 + alignSum * p.alignment + coh * p.cohesion;
|
||||||
|
|
||||||
|
if (p.predatorActive > 0.5) {
|
||||||
|
let dp = wrapped_delta(vec2<f32>(p.predatorX, p.predatorY), pos);
|
||||||
|
let rp = length(dp);
|
||||||
|
if (rp < p.predatorRadius && rp > 0.000001) {
|
||||||
|
acc = acc + dp / rp * p.predatorForce * (1.0 - rp / p.predatorRadius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let newVel = clamp_speed(vel + acc * p.dt, p.maxSpeed, p.maxSpeed * p.minSpeedRatio);
|
||||||
|
let newPos = wrap_position(pos + newVel * p.dt);
|
||||||
|
|
||||||
|
positions[idx] = vec4<f32>(newPos, prev_positions[idx].z, 0.0);
|
||||||
|
velocities[idx] = vec4<f32>(newVel, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Pass 2 (particle life): type matrix, repulsion core, attraction ring.
|
||||||
|
export const PARTICLE_LIFE_STEP_COMPUTE_WGSL = SWARM_SHARED_STRUCTS + SWARM_BINDINGS + `
|
||||||
|
@group(0) @binding(5) var<storage, read> interaction : array<f32>;
|
||||||
|
` + SWARM_HELPERS + `
|
||||||
|
const BETA = 0.3;
|
||||||
|
const MAX_TYPES = 8u;
|
||||||
|
|
||||||
|
fn life_force(x : f32, a : f32) -> f32 {
|
||||||
|
if (x < BETA) { return x / BETA - 1.0; }
|
||||||
|
if (x < 1.0) { return a * (1.0 - abs(2.0 * x - 1.0 - BETA) / (1.0 - BETA)); }
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@compute @workgroup_size(64)
|
||||||
|
fn main(@builtin(global_invocation_id) global_id : vec3<u32>) {
|
||||||
|
let idx = global_id.x;
|
||||||
|
let count = u32(p.count);
|
||||||
|
if (idx >= count) { return; }
|
||||||
|
|
||||||
|
let pos = prev_positions[idx].xy;
|
||||||
|
let typeI = u32(prev_positions[idx].z);
|
||||||
|
var force = vec2<f32>(0.0);
|
||||||
|
|
||||||
|
for (var j = 0u; j < count; j++) {
|
||||||
|
if (j == idx) { continue; }
|
||||||
|
let other = prev_positions[j];
|
||||||
|
let d = wrapped_delta(pos, other.xy);
|
||||||
|
let r = length(d);
|
||||||
|
if (r >= p.interactionRadius || r < 0.000001) { continue; }
|
||||||
|
let a = interaction[typeI * MAX_TYPES + u32(other.z)];
|
||||||
|
force = force + d / r * life_force(r / p.interactionRadius, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newVel = clamp_speed(prev_velocities[idx].xy * p.friction + force * p.forceScale * p.dt, p.maxSpeed, 0.0);
|
||||||
|
let newPos = wrap_position(pos + newVel * p.dt);
|
||||||
|
|
||||||
|
positions[idx] = vec4<f32>(newPos, prev_positions[idx].z, 0.0);
|
||||||
|
velocities[idx] = vec4<f32>(newVel, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Color: heading hue with fewer than 2 types (boids), palette entry of the type otherwise.
|
||||||
|
// Mirrors agentColor and AGENT_PALETTE in boids-cpu-physics.ts.
|
||||||
|
export const AGENT_VERTEX_SHADER_WGSL = SWARM_SHARED_STRUCTS + `
|
||||||
|
var<storage, read> positions : array<vec4<f32>>;
|
||||||
|
var<storage, read> velocities : array<vec4<f32>>;
|
||||||
|
var<storage, read> p : Params;
|
||||||
|
|
||||||
|
uniform viewProjection : mat4x4<f32>;
|
||||||
|
|
||||||
|
varying vColor : vec3<f32>;
|
||||||
|
|
||||||
|
fn agent_color(type_index : u32, vel : vec2<f32>) -> vec3<f32> {
|
||||||
|
if (p.numTypes >= 2.0) {
|
||||||
|
var palette = array<vec3<f32>, 8>(
|
||||||
|
vec3<f32>(0.95, 0.35, 0.30), vec3<f32>(0.30, 0.75, 0.95),
|
||||||
|
vec3<f32>(0.60, 0.90, 0.35), vec3<f32>(0.98, 0.80, 0.25),
|
||||||
|
vec3<f32>(0.75, 0.45, 0.95), vec3<f32>(0.30, 0.90, 0.70),
|
||||||
|
vec3<f32>(0.98, 0.55, 0.80), vec3<f32>(0.90, 0.90, 0.90)
|
||||||
|
);
|
||||||
|
return palette[type_index];
|
||||||
|
}
|
||||||
|
let hue = atan2(vel.y, vel.x) / 6.2831853 + 0.5;
|
||||||
|
let rgb = clamp(abs((hue * 6.0 + vec3<f32>(0.0, 4.0, 2.0)) % 6.0 - 3.0) - 1.0, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||||
|
return mix(vec3<f32>(1.0), rgb, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn main(input : VertexInputs) -> FragmentInputs {
|
||||||
|
var output : FragmentInputs;
|
||||||
|
|
||||||
|
let agent = input.vertexIndex / 3u;
|
||||||
|
let corner = input.vertexIndex % 3u;
|
||||||
|
let pos = positions[agent];
|
||||||
|
let vel = velocities[agent].xy;
|
||||||
|
|
||||||
|
let speed = length(vel);
|
||||||
|
var forward = vec2<f32>(1.0, 0.0);
|
||||||
|
if (speed > 0.000001) { forward = vel / speed; }
|
||||||
|
let right = vec2<f32>(forward.y, -forward.x);
|
||||||
|
|
||||||
|
var offset = forward * p.agentSize;
|
||||||
|
if (corner == 1u) {
|
||||||
|
offset = -forward * p.agentSize * 0.6 + right * p.agentSize * 0.5;
|
||||||
|
} else if (corner == 2u) {
|
||||||
|
offset = -forward * p.agentSize * 0.6 - right * p.agentSize * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.position = uniforms.viewProjection * vec4<f32>(pos.xy + offset, 0.0, 1.0);
|
||||||
|
output.vColor = agent_color(u32(pos.z), vel);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const AGENT_FRAGMENT_SHADER_WGSL = `
|
||||||
|
varying vColor : vec3<f32>;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn main(input : FragmentInputs) -> FragmentOutputs {
|
||||||
|
var output : FragmentOutputs;
|
||||||
|
output.color = vec4<f32>(input.vColor, 1.0);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import {
|
||||||
|
agentColor, AGENT_PALETTE, clampSpeed, copyState, fillParams, particleLifeForce, stepBoids, stepParticleLife, SwarmBuffers,
|
||||||
|
wrapPosition, wrappedDelta, writeAgentColors, writeAgentTriangles
|
||||||
|
} from './boids-cpu-physics';
|
||||||
|
import {LIFE_BETA, MAX_TYPES, ParamIndex, PARAMS_LENGTH, SwarmConfig} from '../boids.model';
|
||||||
|
import {SwarmSimulationParams} from './boids-simulation.strategy';
|
||||||
|
|
||||||
|
function buffers(agents: {x: number; y: number; vx?: number; vy?: number; type?: number}[]): SwarmBuffers {
|
||||||
|
const positions = new Float32Array(agents.length * 4);
|
||||||
|
const velocities = new Float32Array(agents.length * 4);
|
||||||
|
agents.forEach((a, i) => {
|
||||||
|
positions.set([a.x, a.y, a.type ?? 0, 0], i * 4);
|
||||||
|
velocities.set([a.vx ?? 0, a.vy ?? 0, 0, 0], i * 4);
|
||||||
|
});
|
||||||
|
const state = {positions, velocities, prevPositions: new Float32Array(positions.length), prevVelocities: new Float32Array(velocities.length)};
|
||||||
|
copyState(state, agents.length);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function params(count: number, overrides: Partial<Record<keyof typeof ParamIndex, number>> = {}): Float32Array {
|
||||||
|
const p = new Float32Array(PARAMS_LENGTH);
|
||||||
|
const base: Partial<Record<keyof typeof ParamIndex, number>> = {
|
||||||
|
dt: 0.1, count, worldWidth: 100, worldHeight: 100, viewRadius: 10, maxSpeed: 20,
|
||||||
|
separation: 1, alignment: 1, cohesion: 1, predatorRadius: 12, predatorForce: 400,
|
||||||
|
numTypes: 1, interactionRadius: 10, friction: 1, forceScale: 10, minSpeedRatio: 0, ...overrides
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(base)) {
|
||||||
|
p[ParamIndex[key as keyof typeof ParamIndex]] = value;
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = (b: SwarmBuffers, i: number, j: number): number =>
|
||||||
|
b.velocities[i * 4] * b.velocities[j * 4] + b.velocities[i * 4 + 1] * b.velocities[j * 4 + 1];
|
||||||
|
|
||||||
|
describe('boids cpu physics', () => {
|
||||||
|
describe('wrappedDelta / wrapPosition', () => {
|
||||||
|
it('takes the short way across the edge', () => {
|
||||||
|
const {dx, dy} = wrappedDelta(-49, 0, 49, 0, 100, 100);
|
||||||
|
|
||||||
|
expect(dx).toBeCloseTo(-2);
|
||||||
|
expect(dy).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps 51 to -49', () => {
|
||||||
|
expect(wrapPosition(51, 100)).toBeCloseTo(-49);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps -50 and maps 50 to -50', () => {
|
||||||
|
expect(wrapPosition(-50, 100)).toBeCloseTo(-50);
|
||||||
|
expect(wrapPosition(50, 100)).toBeCloseTo(-50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clampSpeed', () => {
|
||||||
|
it('caps above the max', () => {
|
||||||
|
const v = clampSpeed(30, 40, 10, 1);
|
||||||
|
|
||||||
|
expect(Math.hypot(v.vx, v.vy)).toBeCloseTo(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lifts below the min and keeps the direction', () => {
|
||||||
|
const v = clampSpeed(0.1, 0, 10, 2);
|
||||||
|
|
||||||
|
expect(v.vx).toBeCloseTo(2);
|
||||||
|
expect(v.vy).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the zero vector at zero', () => {
|
||||||
|
expect(clampSpeed(0, 0, 10, 2)).toEqual({vx: 0, vy: 0});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stepBoids', () => {
|
||||||
|
it('pushes two boids inside the separation zone apart', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0}, {x: 1, y: 0}]);
|
||||||
|
|
||||||
|
stepBoids(b, params(2, {alignment: 0, cohesion: 0}));
|
||||||
|
|
||||||
|
expect(b.positions[0]).toBeLessThan(0);
|
||||||
|
expect(b.positions[4]).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aligns the headings of two boids in view range', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0, vx: 10, vy: 0}, {x: 8, y: 0, vx: 0, vy: 10}]);
|
||||||
|
const before = dot(b, 0, 1);
|
||||||
|
|
||||||
|
stepBoids(b, params(2, {separation: 0, cohesion: 0}));
|
||||||
|
|
||||||
|
expect(dot(b, 0, 1)).toBeGreaterThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pulls a boid toward its neighbors with cohesion', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0}, {x: 8, y: 0}]);
|
||||||
|
|
||||||
|
stepBoids(b, params(2, {separation: 0, alignment: 0}));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushes a boid away from an active predator', () => {
|
||||||
|
const b = buffers([{x: 5, y: 0, vx: 1, vy: 0}]);
|
||||||
|
|
||||||
|
stepBoids(b, params(1, {predatorActive: 1, predatorX: 0, predatorY: 0}));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a predator that is switched off', () => {
|
||||||
|
const b = buffers([{x: 5, y: 0, vx: 1, vy: 0}]);
|
||||||
|
|
||||||
|
stepBoids(b, params(1, {predatorActive: 0}));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeCloseTo(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the heading of a lonely boid and wraps it at the edge', () => {
|
||||||
|
const b = buffers([{x: 49.5, y: 0, vx: 10, vy: 0}]);
|
||||||
|
|
||||||
|
stepBoids(b, params(1));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeCloseTo(10);
|
||||||
|
expect(b.velocities[1]).toBeCloseTo(0);
|
||||||
|
expect(b.positions[0]).toBeCloseTo(-49.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the previous state, not the already updated one', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0}, {x: 1, y: 0}]);
|
||||||
|
|
||||||
|
stepBoids(b, params(2, {alignment: 0, cohesion: 0}));
|
||||||
|
|
||||||
|
expect(b.positions[0] + b.positions[4]).toBeCloseTo(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('particleLifeForce', () => {
|
||||||
|
it('repels inside the core', () => {
|
||||||
|
expect(particleLifeForce(0.1, 1, 1, LIFE_BETA)).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('peaks at the attraction value in the middle of the ring', () => {
|
||||||
|
expect(particleLifeForce((1 + LIFE_BETA) / 2, 0.7, 1, LIFE_BETA)).toBeCloseTo(0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is zero at the interaction radius', () => {
|
||||||
|
expect(particleLifeForce(1, 1, 1, LIFE_BETA)).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales the distance by rMax', () => {
|
||||||
|
expect(particleLifeForce(5, 0.7, 10, LIFE_BETA)).toBeCloseTo(particleLifeForce(0.5, 0.7, 1, LIFE_BETA));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stepParticleLife', () => {
|
||||||
|
const matrixOf = (value: number): Float32Array => new Float32Array(MAX_TYPES * MAX_TYPES).fill(value);
|
||||||
|
|
||||||
|
it('pulls two agents together with attraction +1', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0}, {x: 6.5, y: 0}]);
|
||||||
|
|
||||||
|
stepParticleLife(b, matrixOf(1), params(2, {numTypes: 2}));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeGreaterThan(0);
|
||||||
|
expect(b.velocities[4]).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushes two agents apart with attraction -1', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0}, {x: 6.5, y: 0}]);
|
||||||
|
|
||||||
|
stepParticleLife(b, matrixOf(-1), params(2, {numTypes: 2}));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeLessThan(0);
|
||||||
|
expect(b.velocities[4]).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales the old velocity with friction', () => {
|
||||||
|
const b = buffers([{x: 0, y: 0, vx: 10, vy: 0}]);
|
||||||
|
|
||||||
|
stepParticleLife(b, matrixOf(0), params(1, {friction: 0.5}));
|
||||||
|
|
||||||
|
expect(b.velocities[0]).toBeCloseTo(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fillParams', () => {
|
||||||
|
const config: SwarmConfig = {mode: 'particleLife', count: 123, numTypes: 4, matrix: new Float32Array()};
|
||||||
|
const simulation: SwarmSimulationParams = {
|
||||||
|
deltaTime: 0.5, separation: 1.1, alignment: 1.2, cohesion: 1.3, viewRadius: 7, maxSpeed: 21,
|
||||||
|
predator: {x: 3, y: -4, active: true}, interactionRadius: 9, friction: 0.8, forceScale: 55
|
||||||
|
};
|
||||||
|
|
||||||
|
it('writes every index', () => {
|
||||||
|
const target = new Float32Array(PARAMS_LENGTH).fill(NaN);
|
||||||
|
|
||||||
|
fillParams(target, config, simulation);
|
||||||
|
|
||||||
|
expect(Array.from(target).some(Number.isNaN)).toBe(false);
|
||||||
|
expect(target[ParamIndex.count]).toBe(123);
|
||||||
|
expect(target[ParamIndex.numTypes]).toBe(4);
|
||||||
|
expect(target[ParamIndex.predatorX]).toBe(3);
|
||||||
|
expect(target[ParamIndex.predatorActive]).toBe(1);
|
||||||
|
expect(target[ParamIndex.maxSpeed]).toBe(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps dt to 1/30', () => {
|
||||||
|
const target = new Float32Array(PARAMS_LENGTH);
|
||||||
|
|
||||||
|
fillParams(target, config, simulation);
|
||||||
|
|
||||||
|
expect(target[ParamIndex.dt]).toBeCloseTo(1 / 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a small dt', () => {
|
||||||
|
const target = new Float32Array(PARAMS_LENGTH);
|
||||||
|
|
||||||
|
fillParams(target, config, {...simulation, deltaTime: 0.01});
|
||||||
|
|
||||||
|
expect(target[ParamIndex.dt]).toBeCloseTo(0.01);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeAgentTriangles', () => {
|
||||||
|
it('puts the tip one size ahead along the velocity', () => {
|
||||||
|
const out = new Float32Array(9);
|
||||||
|
|
||||||
|
writeAgentTriangles(new Float32Array([5, 5, 0, 0]), new Float32Array([0, 3, 0, 0]), 1, 2, out);
|
||||||
|
|
||||||
|
expect(out[0]).toBeCloseTo(5);
|
||||||
|
expect(out[1]).toBeCloseTo(7);
|
||||||
|
expect(out[2]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('points to +x for a zero velocity', () => {
|
||||||
|
const out = new Float32Array(9);
|
||||||
|
|
||||||
|
writeAgentTriangles(new Float32Array([0, 0, 0, 0]), new Float32Array(4), 1, 2, out);
|
||||||
|
|
||||||
|
expect(out[0]).toBeCloseTo(2);
|
||||||
|
expect(out[1]).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('agentColor', () => {
|
||||||
|
it('uses the palette entry of the type with 2 or more types', () => {
|
||||||
|
expect(agentColor(3, 2, 5, 5)).toEqual(AGENT_PALETTE[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colors by heading with a single type', () => {
|
||||||
|
const east = agentColor(1, 0, 10, 0);
|
||||||
|
const north = agentColor(1, 0, 0, 10);
|
||||||
|
|
||||||
|
expect(east).not.toEqual(north);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the same color for the same heading at different speeds', () => {
|
||||||
|
const slow = agentColor(1, 0, 1, 1);
|
||||||
|
const fast = agentColor(1, 0, 10, 10);
|
||||||
|
|
||||||
|
slow.forEach((c, i) => expect(c).toBeCloseTo(fast[i]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the color to all three corners', () => {
|
||||||
|
const out = new Float32Array(9);
|
||||||
|
|
||||||
|
writeAgentColors(new Float32Array([0, 0, 1, 0]), new Float32Array(4), 1, 2, out);
|
||||||
|
|
||||||
|
for (let corner = 0; corner < 3; corner++) {
|
||||||
|
expect(Array.from(out.slice(corner * 3, corner * 3 + 3)).map(v => Math.round(v * 100)))
|
||||||
|
.toEqual(AGENT_PALETTE[1].map(v => Math.round(v * 100)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import {
|
||||||
|
AGENT_SIZE, LIFE_BETA, MAX_TYPES, MIN_SPEED_RATIO, ParamIndex, PREDATOR_FORCE, PREDATOR_RADIUS, SwarmConfig,
|
||||||
|
WORLD_SIZE
|
||||||
|
} from '../boids.model';
|
||||||
|
import {SwarmSimulationParams} from './boids-simulation.strategy';
|
||||||
|
|
||||||
|
export interface SwarmBuffers {
|
||||||
|
positions: Float32Array;
|
||||||
|
velocities: Float32Array;
|
||||||
|
prevPositions: Float32Array;
|
||||||
|
prevVelocities: Float32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same colors as PALETTE in boids.shader.ts. */
|
||||||
|
export const AGENT_PALETTE: readonly (readonly [number, number, number])[] = [
|
||||||
|
[0.95, 0.35, 0.30], [0.30, 0.75, 0.95], [0.60, 0.90, 0.35], [0.98, 0.80, 0.25],
|
||||||
|
[0.75, 0.45, 0.95], [0.30, 0.90, 0.70], [0.98, 0.55, 0.80], [0.90, 0.90, 0.90]
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_DT = 1 / 30;
|
||||||
|
const EPSILON = 1e-6;
|
||||||
|
|
||||||
|
/** Writes the Params float array the shaders read. `dt` is clamped so a tab switch does not explode the simulation. */
|
||||||
|
export function fillParams(target: Float32Array, config: SwarmConfig, params: SwarmSimulationParams): void {
|
||||||
|
target[ParamIndex.dt] = Math.min(params.deltaTime, MAX_DT);
|
||||||
|
target[ParamIndex.count] = config.count;
|
||||||
|
target[ParamIndex.worldWidth] = WORLD_SIZE;
|
||||||
|
target[ParamIndex.worldHeight] = WORLD_SIZE;
|
||||||
|
target[ParamIndex.viewRadius] = params.viewRadius;
|
||||||
|
target[ParamIndex.maxSpeed] = params.maxSpeed;
|
||||||
|
target[ParamIndex.separation] = params.separation;
|
||||||
|
target[ParamIndex.alignment] = params.alignment;
|
||||||
|
target[ParamIndex.cohesion] = params.cohesion;
|
||||||
|
target[ParamIndex.predatorX] = params.predator.x;
|
||||||
|
target[ParamIndex.predatorY] = params.predator.y;
|
||||||
|
target[ParamIndex.predatorRadius] = PREDATOR_RADIUS;
|
||||||
|
target[ParamIndex.predatorActive] = params.predator.active ? 1 : 0;
|
||||||
|
target[ParamIndex.numTypes] = config.numTypes;
|
||||||
|
target[ParamIndex.interactionRadius] = params.interactionRadius;
|
||||||
|
target[ParamIndex.friction] = params.friction;
|
||||||
|
target[ParamIndex.forceScale] = params.forceScale;
|
||||||
|
target[ParamIndex.predatorForce] = PREDATOR_FORCE;
|
||||||
|
target[ParamIndex.agentSize] = AGENT_SIZE;
|
||||||
|
target[ParamIndex.minSpeedRatio] = MIN_SPEED_RATIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyState(buffers: SwarmBuffers, count: number): void {
|
||||||
|
buffers.prevPositions.set(buffers.positions.subarray(0, count * 4));
|
||||||
|
buffers.prevVelocities.set(buffers.velocities.subarray(0, count * 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shortest vector a -> b on the torus. */
|
||||||
|
export function wrappedDelta(ax: number, ay: number, bx: number, by: number, worldW: number, worldH: number): {dx: number; dy: number} {
|
||||||
|
const dx = bx - ax;
|
||||||
|
const dy = by - ay;
|
||||||
|
return {dx: dx - worldW * Math.round(dx / worldW), dy: dy - worldH * Math.round(dy / worldH)};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a value into [-size/2, size/2). */
|
||||||
|
export function wrapPosition(value: number, size: number): number {
|
||||||
|
const shifted = value + size / 2;
|
||||||
|
return shifted - size * Math.floor(shifted / size) - size / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampSpeed(vx: number, vy: number, maxSpeed: number, minSpeed: number): {vx: number; vy: number} {
|
||||||
|
const speed = Math.hypot(vx, vy);
|
||||||
|
if (speed < EPSILON) {
|
||||||
|
return {vx, vy};
|
||||||
|
}
|
||||||
|
if (speed > maxSpeed) {
|
||||||
|
return {vx: vx / speed * maxSpeed, vy: vy / speed * maxSpeed};
|
||||||
|
}
|
||||||
|
if (speed < minSpeed) {
|
||||||
|
return {vx: vx / speed * minSpeed, vy: vy / speed * minSpeed};
|
||||||
|
}
|
||||||
|
return {vx, vy};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors BOIDS_STEP_COMPUTE_WGSL. Reads prev*, writes positions/velocities. */
|
||||||
|
export function stepBoids(buffers: SwarmBuffers, params: Float32Array): void {
|
||||||
|
const count = params[ParamIndex.count];
|
||||||
|
const dt = params[ParamIndex.dt];
|
||||||
|
const worldW = params[ParamIndex.worldWidth];
|
||||||
|
const worldH = params[ParamIndex.worldHeight];
|
||||||
|
const viewRadius = params[ParamIndex.viewRadius];
|
||||||
|
const maxSpeed = params[ParamIndex.maxSpeed];
|
||||||
|
const separation = params[ParamIndex.separation] * 10;
|
||||||
|
const alignment = params[ParamIndex.alignment];
|
||||||
|
const cohesion = params[ParamIndex.cohesion];
|
||||||
|
const {prevPositions: pp, prevVelocities: pv} = buffers;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const px = pp[i * 4];
|
||||||
|
const py = pp[i * 4 + 1];
|
||||||
|
const vx = pv[i * 4];
|
||||||
|
const vy = pv[i * 4 + 1];
|
||||||
|
let sepX = 0, sepY = 0, alignX = 0, alignY = 0, cohX = 0, cohY = 0, n = 0;
|
||||||
|
|
||||||
|
for (let j = 0; j < count; j++) {
|
||||||
|
if (j === i) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const {dx, dy} = wrappedDelta(px, py, pp[j * 4], pp[j * 4 + 1], worldW, worldH);
|
||||||
|
const r = Math.hypot(dx, dy);
|
||||||
|
if (r >= viewRadius || r < EPSILON) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (r < viewRadius * 0.5) {
|
||||||
|
sepX -= dx / (r * r);
|
||||||
|
sepY -= dy / (r * r);
|
||||||
|
}
|
||||||
|
alignX += pv[j * 4];
|
||||||
|
alignY += pv[j * 4 + 1];
|
||||||
|
cohX += dx;
|
||||||
|
cohY += dy;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
if (n > 0) {
|
||||||
|
alignX = alignX / n - vx;
|
||||||
|
alignY = alignY / n - vy;
|
||||||
|
cohX /= n;
|
||||||
|
cohY /= n;
|
||||||
|
}
|
||||||
|
|
||||||
|
let accX = sepX * separation + alignX * alignment + cohX * cohesion;
|
||||||
|
let accY = sepY * separation + alignY * alignment + cohY * cohesion;
|
||||||
|
|
||||||
|
if (params[ParamIndex.predatorActive] > 0.5) {
|
||||||
|
const predatorRadius = params[ParamIndex.predatorRadius];
|
||||||
|
const {dx, dy} = wrappedDelta(params[ParamIndex.predatorX], params[ParamIndex.predatorY], px, py, worldW, worldH);
|
||||||
|
const r = Math.hypot(dx, dy);
|
||||||
|
if (r < predatorRadius && r > EPSILON) {
|
||||||
|
const push = params[ParamIndex.predatorForce] * (1 - r / predatorRadius);
|
||||||
|
accX += dx / r * push;
|
||||||
|
accY += dy / r * push;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = clampSpeed(vx + accX * dt, vy + accY * dt, maxSpeed, maxSpeed * params[ParamIndex.minSpeedRatio]);
|
||||||
|
buffers.velocities[i * 4] = v.vx;
|
||||||
|
buffers.velocities[i * 4 + 1] = v.vy;
|
||||||
|
buffers.positions[i * 4] = wrapPosition(px + v.vx * dt, worldW);
|
||||||
|
buffers.positions[i * 4 + 1] = wrapPosition(py + v.vy * dt, worldH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 1D force profile. `r` is a distance, `rMax` the interaction radius. */
|
||||||
|
export function particleLifeForce(r: number, attraction: number, rMax: number, beta: number): number {
|
||||||
|
const x = r / rMax;
|
||||||
|
if (x < beta) {
|
||||||
|
return x / beta - 1;
|
||||||
|
}
|
||||||
|
if (x < 1) {
|
||||||
|
return attraction * (1 - Math.abs(2 * x - 1 - beta) / (1 - beta));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors PARTICLE_LIFE_STEP_COMPUTE_WGSL. Reads prev*, writes positions/velocities. */
|
||||||
|
export function stepParticleLife(buffers: SwarmBuffers, matrix: Float32Array, params: Float32Array): void {
|
||||||
|
const count = params[ParamIndex.count];
|
||||||
|
const dt = params[ParamIndex.dt];
|
||||||
|
const worldW = params[ParamIndex.worldWidth];
|
||||||
|
const worldH = params[ParamIndex.worldHeight];
|
||||||
|
const radius = params[ParamIndex.interactionRadius];
|
||||||
|
const friction = params[ParamIndex.friction];
|
||||||
|
const forceScale = params[ParamIndex.forceScale];
|
||||||
|
const maxSpeed = params[ParamIndex.maxSpeed];
|
||||||
|
const {prevPositions: pp, prevVelocities: pv} = buffers;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const px = pp[i * 4];
|
||||||
|
const py = pp[i * 4 + 1];
|
||||||
|
const typeI = pp[i * 4 + 2];
|
||||||
|
let forceX = 0, forceY = 0;
|
||||||
|
|
||||||
|
for (let j = 0; j < count; j++) {
|
||||||
|
if (j === i) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const {dx, dy} = wrappedDelta(px, py, pp[j * 4], pp[j * 4 + 1], worldW, worldH);
|
||||||
|
const r = Math.hypot(dx, dy);
|
||||||
|
if (r >= radius || r < EPSILON) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const attraction = matrix[typeI * MAX_TYPES + pp[j * 4 + 2]];
|
||||||
|
const f = particleLifeForce(r, attraction, radius, LIFE_BETA);
|
||||||
|
forceX += dx / r * f;
|
||||||
|
forceY += dy / r * f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = clampSpeed(
|
||||||
|
pv[i * 4] * friction + forceX * forceScale * dt,
|
||||||
|
pv[i * 4 + 1] * friction + forceY * forceScale * dt,
|
||||||
|
maxSpeed, 0);
|
||||||
|
buffers.velocities[i * 4] = v.vx;
|
||||||
|
buffers.velocities[i * 4 + 1] = v.vy;
|
||||||
|
buffers.positions[i * 4] = wrapPosition(px + v.vx * dt, worldW);
|
||||||
|
buffers.positions[i * 4 + 1] = wrapPosition(py + v.vy * dt, worldH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes 9 floats per agent (3 corners, z = 0). The tip points along the velocity. */
|
||||||
|
export function writeAgentTriangles(positions: Float32Array, velocities: Float32Array, count: number, size: number, out: Float32Array): void {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const speed = Math.hypot(velocities[i * 4], velocities[i * 4 + 1]);
|
||||||
|
const fx = speed < EPSILON ? 1 : velocities[i * 4] / speed;
|
||||||
|
const fy = speed < EPSILON ? 0 : velocities[i * 4 + 1] / speed;
|
||||||
|
const rx = fy;
|
||||||
|
const ry = -fx;
|
||||||
|
const x = positions[i * 4];
|
||||||
|
const y = positions[i * 4 + 1];
|
||||||
|
const back = size * 0.6;
|
||||||
|
const side = size * 0.5;
|
||||||
|
const o = i * 9;
|
||||||
|
out[o] = x + fx * size;
|
||||||
|
out[o + 1] = y + fy * size;
|
||||||
|
out[o + 2] = 0;
|
||||||
|
out[o + 3] = x - fx * back + rx * side;
|
||||||
|
out[o + 4] = y - fy * back + ry * side;
|
||||||
|
out[o + 5] = 0;
|
||||||
|
out[o + 6] = x - fx * back - rx * side;
|
||||||
|
out[o + 7] = y - fy * back - ry * side;
|
||||||
|
out[o + 8] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RGB of one agent. With fewer than 2 types (boids) the hue follows the heading,
|
||||||
|
* otherwise the color is the palette entry of the type. Mirrors the fragment path in boids.shader.ts.
|
||||||
|
*/
|
||||||
|
export function agentColor(numTypes: number, type: number, vx: number, vy: number): readonly [number, number, number] {
|
||||||
|
if (numTypes >= 2) {
|
||||||
|
return AGENT_PALETTE[type];
|
||||||
|
}
|
||||||
|
const hue = Math.atan2(vy, vx) / (2 * Math.PI) + 0.5;
|
||||||
|
const channel = (offset: number): number => {
|
||||||
|
const t = hue + offset;
|
||||||
|
return Math.min(1, Math.max(0, Math.abs((t - Math.floor(t)) * 6 - 3) - 1));
|
||||||
|
};
|
||||||
|
const saturation = 0.7;
|
||||||
|
const mix = (c: number): number => 1 - saturation + saturation * c;
|
||||||
|
return [mix(channel(0)), mix(channel(2 / 3)), mix(channel(1 / 3))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes 9 floats per agent (one RGB per corner). */
|
||||||
|
export function writeAgentColors(positions: Float32Array, velocities: Float32Array, count: number, numTypes: number, out: Float32Array): void {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const [r, g, b] = agentColor(numTypes, positions[i * 4 + 2], velocities[i * 4], velocities[i * 4 + 1]);
|
||||||
|
for (let corner = 0; corner < 3; corner++) {
|
||||||
|
out[i * 9 + corner * 3] = r;
|
||||||
|
out[i * 9 + corner * 3 + 1] = g;
|
||||||
|
out[i * 9 + corner * 3 + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {BOIDS_DEFAULTS} from '../boids.model';
|
||||||
|
import {BoidsCpuStrategy} from './boids-cpu.strategy';
|
||||||
|
import {SwarmSimulationParams} from './boids-simulation.strategy';
|
||||||
|
|
||||||
|
const positionUploads: Float32Array[] = [];
|
||||||
|
|
||||||
|
vi.mock('@babylonjs/core', () => {
|
||||||
|
class FakeMesh {
|
||||||
|
alwaysSelectAsActiveMesh = false;
|
||||||
|
material: unknown = null;
|
||||||
|
setVerticesData(): void { /* initial buffers are not under test */ }
|
||||||
|
updateVerticesData(kind: string, data: Float32Array): void {
|
||||||
|
if (kind === 'position') {
|
||||||
|
positionUploads.push(data.slice());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dispose(): void { /* nothing to free */ }
|
||||||
|
}
|
||||||
|
class FakeVertexData {
|
||||||
|
positions: Float32Array | null = null;
|
||||||
|
indices: number[] | null = null;
|
||||||
|
applyToMesh(): void { /* initial buffers are not under test */ }
|
||||||
|
}
|
||||||
|
class FakeMaterial {
|
||||||
|
backFaceCulling = true;
|
||||||
|
disableDepthWrite = false;
|
||||||
|
}
|
||||||
|
return {Mesh: FakeMesh, VertexData: FakeVertexData, VertexBuffer: {PositionKind: 'position'}, ShaderMaterial: FakeMaterial};
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeScene() {
|
||||||
|
const callbacks: (() => void)[] = [];
|
||||||
|
return {
|
||||||
|
onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)},
|
||||||
|
frame: () => callbacks.forEach((cb) => cb())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramsWithDelta(deltaTime: number): SwarmSimulationParams {
|
||||||
|
return {
|
||||||
|
deltaTime,
|
||||||
|
...BOIDS_DEFAULTS,
|
||||||
|
predator: {x: 0, y: 0, active: false},
|
||||||
|
interactionRadius: 0,
|
||||||
|
friction: 0,
|
||||||
|
forceScale: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BoidsCpuStrategy', () => {
|
||||||
|
it('readsParamsAtFrameStart', () => {
|
||||||
|
positionUploads.length = 0;
|
||||||
|
let deltaTime = 0;
|
||||||
|
const scene = fakeScene();
|
||||||
|
const strategy = new BoidsCpuStrategy();
|
||||||
|
strategy.init(scene as never, {} as never, {mode: 'boids', count: 16, numTypes: 1, matrix: new Float32Array(64)}, () => paramsWithDelta(deltaTime));
|
||||||
|
scene.frame();
|
||||||
|
|
||||||
|
deltaTime = 0.016;
|
||||||
|
scene.frame();
|
||||||
|
|
||||||
|
expect(positionUploads).toHaveLength(2);
|
||||||
|
expect(positionUploads[1]).not.toEqual(positionUploads[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {Engine, Mesh, Scene, ShaderMaterial, VertexBuffer, VertexData, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {AGENT_SIZE, MAX_TYPES, PARAMS_LENGTH, SwarmConfig, seedAgents} from '../boids.model';
|
||||||
|
import {AGENT_FRAGMENT_SHADER_GLSL, AGENT_VERTEX_SHADER_GLSL} from '../boids-glsl.shader';
|
||||||
|
import {copyState, fillParams, stepBoids, stepParticleLife, SwarmBuffers, writeAgentColors, writeAgentTriangles} from './boids-cpu-physics';
|
||||||
|
import {SwarmSimulationParams, SwarmSimulationStrategy} from './boids-simulation.strategy';
|
||||||
|
|
||||||
|
const COLOR_ATTRIBUTE = 'agentColor';
|
||||||
|
|
||||||
|
export class BoidsCpuStrategy implements SwarmSimulationStrategy {
|
||||||
|
private scene: Scene | null = null;
|
||||||
|
private mesh: Mesh | null = null;
|
||||||
|
private config: SwarmConfig | null = null;
|
||||||
|
private buffers: SwarmBuffers | null = null;
|
||||||
|
private matrix: Float32Array = new Float32Array(MAX_TYPES * MAX_TYPES);
|
||||||
|
private readonly params = new Float32Array(PARAMS_LENGTH);
|
||||||
|
private readParams: (() => SwarmSimulationParams) | null = null;
|
||||||
|
|
||||||
|
init(scene: Scene, _engine: WebGPUEngine | Engine, config: SwarmConfig, readParams: () => SwarmSimulationParams): void {
|
||||||
|
this.scene = scene;
|
||||||
|
this.readParams = readParams;
|
||||||
|
this.config = config;
|
||||||
|
this.matrix = config.matrix;
|
||||||
|
|
||||||
|
const data = seedAgents(config);
|
||||||
|
this.buffers = {
|
||||||
|
positions: data.positions,
|
||||||
|
velocities: data.velocities,
|
||||||
|
prevPositions: new Float32Array(data.positions.length),
|
||||||
|
prevVelocities: new Float32Array(data.velocities.length)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setupRenderMesh(scene, config);
|
||||||
|
this.startRenderLoop(scene, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInteractionMatrix(matrix: Float32Array): void {
|
||||||
|
this.matrix = matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.scene?.onBeforeRenderObservable.clear();
|
||||||
|
this.mesh?.dispose(false, true);
|
||||||
|
this.mesh = null;
|
||||||
|
this.buffers = null;
|
||||||
|
this.config = null;
|
||||||
|
this.readParams = null;
|
||||||
|
this.scene = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyParams(params: SwarmSimulationParams): void {
|
||||||
|
if (this.config) {
|
||||||
|
fillParams(this.params, this.config, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRenderMesh(scene: Scene, config: SwarmConfig): void {
|
||||||
|
const mesh = new Mesh('agents', scene);
|
||||||
|
const vertexData = new VertexData();
|
||||||
|
vertexData.positions = new Float32Array(config.count * 9);
|
||||||
|
vertexData.indices = Array.from({length: config.count * 3}, (_, i) => i);
|
||||||
|
vertexData.applyToMesh(mesh, true);
|
||||||
|
mesh.setVerticesData(COLOR_ATTRIBUTE, new Float32Array(config.count * 9), true, 3);
|
||||||
|
mesh.alwaysSelectAsActiveMesh = true;
|
||||||
|
|
||||||
|
const material = new ShaderMaterial('agentMat', scene, {
|
||||||
|
vertexSource: AGENT_VERTEX_SHADER_GLSL,
|
||||||
|
fragmentSource: AGENT_FRAGMENT_SHADER_GLSL
|
||||||
|
}, {
|
||||||
|
attributes: ['position', COLOR_ATTRIBUTE],
|
||||||
|
uniforms: ['viewProjection']
|
||||||
|
});
|
||||||
|
material.backFaceCulling = false;
|
||||||
|
material.disableDepthWrite = true;
|
||||||
|
mesh.material = material;
|
||||||
|
this.mesh = mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRenderLoop(scene: Scene, config: SwarmConfig): void {
|
||||||
|
const triangles = new Float32Array(config.count * 9);
|
||||||
|
const colors = new Float32Array(config.count * 9);
|
||||||
|
|
||||||
|
scene.onBeforeRenderObservable.clear();
|
||||||
|
scene.onBeforeRenderObservable.add(() => {
|
||||||
|
if (!this.mesh || !this.buffers || !this.readParams) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyParams(this.readParams());
|
||||||
|
copyState(this.buffers, config.count);
|
||||||
|
if (config.mode === 'boids') {
|
||||||
|
stepBoids(this.buffers, this.params);
|
||||||
|
} else {
|
||||||
|
stepParticleLife(this.buffers, this.matrix, this.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAgentTriangles(this.buffers.positions, this.buffers.velocities, config.count, AGENT_SIZE, triangles);
|
||||||
|
writeAgentColors(this.buffers.positions, this.buffers.velocities, config.count, config.numTypes, colors);
|
||||||
|
this.mesh.updateVerticesData(VertexBuffer.PositionKind, triangles);
|
||||||
|
this.mesh.updateVerticesData(COLOR_ATTRIBUTE, colors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {ComputeShader, Engine, Mesh, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, VertexData, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {PARAMS_LENGTH, SwarmConfig, WORKGROUP_SIZE, seedAgents} from '../boids.model';
|
||||||
|
import {
|
||||||
|
AGENT_FRAGMENT_SHADER_WGSL,
|
||||||
|
AGENT_VERTEX_SHADER_WGSL,
|
||||||
|
BOIDS_STEP_COMPUTE_WGSL,
|
||||||
|
PARTICLE_LIFE_STEP_COMPUTE_WGSL,
|
||||||
|
SWARM_COPY_COMPUTE_WGSL
|
||||||
|
} from '../boids.shader';
|
||||||
|
import {fillParams} from './boids-cpu-physics';
|
||||||
|
import {SwarmSimulationParams, SwarmSimulationStrategy} from './boids-simulation.strategy';
|
||||||
|
|
||||||
|
const AGENT_BYTES = 16;
|
||||||
|
|
||||||
|
interface GpuBuffers {
|
||||||
|
positions: StorageBuffer;
|
||||||
|
velocities: StorageBuffer;
|
||||||
|
prevPositions: StorageBuffer;
|
||||||
|
prevVelocities: StorageBuffer;
|
||||||
|
params: StorageBuffer;
|
||||||
|
matrix: StorageBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GpuPipelines {
|
||||||
|
copy: ComputeShader;
|
||||||
|
step: ComputeShader;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BoidsGpuStrategy implements SwarmSimulationStrategy {
|
||||||
|
private scene: Scene | null = null;
|
||||||
|
private mesh: Mesh | null = null;
|
||||||
|
private config: SwarmConfig | null = null;
|
||||||
|
private buffers: GpuBuffers | null = null;
|
||||||
|
private pipelines: GpuPipelines | null = null;
|
||||||
|
private dispatchX = 0;
|
||||||
|
private readonly paramsData = new Float32Array(PARAMS_LENGTH);
|
||||||
|
private readParams: (() => SwarmSimulationParams) | null = null;
|
||||||
|
|
||||||
|
init(scene: Scene, engine: WebGPUEngine | Engine, config: SwarmConfig, readParams: () => SwarmSimulationParams): void {
|
||||||
|
this.scene = scene;
|
||||||
|
this.readParams = readParams;
|
||||||
|
this.config = config;
|
||||||
|
const gpuEngine = engine as WebGPUEngine;
|
||||||
|
|
||||||
|
this.buffers = this.createStorageBuffers(gpuEngine, config);
|
||||||
|
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers, config);
|
||||||
|
this.setupRenderMesh(scene, config, this.buffers);
|
||||||
|
this.dispatchX = Math.ceil(config.count / WORKGROUP_SIZE);
|
||||||
|
this.startRenderLoop(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInteractionMatrix(matrix: Float32Array): void {
|
||||||
|
this.buffers?.matrix.update(matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.scene?.onBeforeRenderObservable.clear();
|
||||||
|
this.mesh?.dispose(false, true);
|
||||||
|
if (this.buffers) {
|
||||||
|
Object.values(this.buffers).forEach(buffer => buffer.dispose());
|
||||||
|
}
|
||||||
|
this.mesh = null;
|
||||||
|
this.buffers = null;
|
||||||
|
this.pipelines = null;
|
||||||
|
this.config = null;
|
||||||
|
this.readParams = null;
|
||||||
|
this.scene = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyParams(params: SwarmSimulationParams): void {
|
||||||
|
if (this.config) {
|
||||||
|
fillParams(this.paramsData, this.config, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createStorageBuffers(engine: WebGPUEngine, config: SwarmConfig): GpuBuffers {
|
||||||
|
const data = seedAgents(config);
|
||||||
|
const createBuffer = (arrayData: Float32Array): StorageBuffer => {
|
||||||
|
const buffer = new StorageBuffer(engine, arrayData.length * 4);
|
||||||
|
buffer.update(arrayData);
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
positions: createBuffer(data.positions),
|
||||||
|
velocities: createBuffer(data.velocities),
|
||||||
|
prevPositions: new StorageBuffer(engine, config.count * AGENT_BYTES),
|
||||||
|
prevVelocities: new StorageBuffer(engine, config.count * AGENT_BYTES),
|
||||||
|
params: createBuffer(data.params),
|
||||||
|
matrix: createBuffer(config.matrix)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupComputePipelines(engine: WebGPUEngine, buffers: GpuBuffers, config: SwarmConfig): GpuPipelines {
|
||||||
|
const createShader = (name: string, source: string, withMatrix: boolean): ComputeShader => {
|
||||||
|
const cs = new ComputeShader(name, engine, {computeSource: source}, {
|
||||||
|
bindingsMapping: {
|
||||||
|
'p': {group: 0, binding: 0},
|
||||||
|
'positions': {group: 0, binding: 1},
|
||||||
|
'velocities': {group: 0, binding: 2},
|
||||||
|
'prev_positions': {group: 0, binding: 3},
|
||||||
|
'prev_velocities': {group: 0, binding: 4},
|
||||||
|
...(withMatrix ? {'interaction': {group: 0, binding: 5}} : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cs.setStorageBuffer('p', buffers.params);
|
||||||
|
cs.setStorageBuffer('positions', buffers.positions);
|
||||||
|
cs.setStorageBuffer('velocities', buffers.velocities);
|
||||||
|
cs.setStorageBuffer('prev_positions', buffers.prevPositions);
|
||||||
|
cs.setStorageBuffer('prev_velocities', buffers.prevVelocities);
|
||||||
|
if (withMatrix) {
|
||||||
|
cs.setStorageBuffer('interaction', buffers.matrix);
|
||||||
|
}
|
||||||
|
return cs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBoids = config.mode === 'boids';
|
||||||
|
return {
|
||||||
|
copy: createShader('swarmCopy', SWARM_COPY_COMPUTE_WGSL, false),
|
||||||
|
step: createShader(
|
||||||
|
isBoids ? 'boidsStep' : 'particleLifeStep',
|
||||||
|
isBoids ? BOIDS_STEP_COMPUTE_WGSL : PARTICLE_LIFE_STEP_COMPUTE_WGSL,
|
||||||
|
!isBoids)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRenderMesh(scene: Scene, config: SwarmConfig, buffers: GpuBuffers): void {
|
||||||
|
const mesh = new Mesh('agents', scene);
|
||||||
|
const vertexData = new VertexData();
|
||||||
|
vertexData.positions = new Float32Array(config.count * 9);
|
||||||
|
vertexData.indices = Array.from({length: config.count * 3}, (_, i) => i);
|
||||||
|
vertexData.applyToMesh(mesh);
|
||||||
|
mesh.alwaysSelectAsActiveMesh = true;
|
||||||
|
|
||||||
|
const material = new ShaderMaterial('agentMatWgsl', scene, {
|
||||||
|
vertexSource: AGENT_VERTEX_SHADER_WGSL,
|
||||||
|
fragmentSource: AGENT_FRAGMENT_SHADER_WGSL
|
||||||
|
}, {
|
||||||
|
attributes: ['position'],
|
||||||
|
uniforms: ['viewProjection'],
|
||||||
|
storageBuffers: ['positions', 'velocities', 'p'],
|
||||||
|
shaderLanguage: ShaderLanguage.WGSL
|
||||||
|
});
|
||||||
|
material.backFaceCulling = false;
|
||||||
|
material.disableDepthWrite = true;
|
||||||
|
material.setStorageBuffer('positions', buffers.positions);
|
||||||
|
material.setStorageBuffer('velocities', buffers.velocities);
|
||||||
|
material.setStorageBuffer('p', buffers.params);
|
||||||
|
mesh.material = material;
|
||||||
|
this.mesh = mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRenderLoop(scene: Scene): void {
|
||||||
|
scene.onBeforeRenderObservable.clear();
|
||||||
|
scene.onBeforeRenderObservable.add(() => {
|
||||||
|
if (!this.buffers || !this.pipelines || !this.readParams) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyParams(this.readParams());
|
||||||
|
this.buffers.params.update(this.paramsData);
|
||||||
|
this.pipelines.copy.dispatch(this.dispatchX, 1, 1);
|
||||||
|
this.pipelines.step.dispatch(this.dispatchX, 1, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {Engine, Scene, WebGPUEngine} from '@babylonjs/core';
|
||||||
|
import {SwarmConfig} from '../boids.model';
|
||||||
|
|
||||||
|
export interface SwarmSimulationParams {
|
||||||
|
deltaTime: number;
|
||||||
|
separation: number;
|
||||||
|
alignment: number;
|
||||||
|
cohesion: number;
|
||||||
|
viewRadius: number;
|
||||||
|
maxSpeed: number;
|
||||||
|
predator: {x: number; y: number; active: boolean};
|
||||||
|
interactionRadius: number;
|
||||||
|
friction: number;
|
||||||
|
forceScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwarmSimulationStrategy {
|
||||||
|
init(scene: Scene, engine: WebGPUEngine | Engine, config: SwarmConfig, readParams: () => SwarmSimulationParams): void;
|
||||||
|
/** Re-roll without restart. */
|
||||||
|
setInteractionMatrix(matrix: Float32Array): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import {
|
||||||
|
ArcRotateCamera, Color3, Color4, CreateLineSystemVertexData, LinesMesh, Mesh, MeshBuilder, Scene, ShaderMaterial, StandardMaterial, Vector3, VertexBuffer, VertexData
|
||||||
|
} from '@babylonjs/core';
|
||||||
|
import {CLOTH_FRAGMENT_SHADER_GLSL, CLOTH_VERTEX_SHADER_GLSL} from '../cloth/cloth-glsl.shader';
|
||||||
|
import {ClothIcmScene} from './cloth-icm-scenes';
|
||||||
|
import {ClothIcmSolver} from './cloth-icm-solver';
|
||||||
|
import {ClothBvh} from './collision/cloth-bvh';
|
||||||
|
import {ClothTopology} from './collision/cloth-topology';
|
||||||
|
import {DOP_SIZE} from './collision/dop18';
|
||||||
|
import {Contour} from './collision/intersection-contours';
|
||||||
|
import {IntersectionSet} from './collision/intersection-set';
|
||||||
|
|
||||||
|
const CONTOUR_COLOR = new Color3(0.1, 1, 0.2);
|
||||||
|
const CULLED_COLOR = new Color4(0.2, 0.8, 1, 1);
|
||||||
|
const TESTED_COLOR = new Color4(0.6, 0.6, 0.6, 1);
|
||||||
|
const OBSTACLE_COLOR = new Color3(0.35, 0.55, 0.9);
|
||||||
|
const GROUND_SIZE = 8;
|
||||||
|
/** Lines of group 1 are drawn after the cloth with a cleared depth buffer, so they are never hidden by it. */
|
||||||
|
const OVERLAY_RENDERING_GROUP = 1;
|
||||||
|
|
||||||
|
interface BvhBox {
|
||||||
|
node: number;
|
||||||
|
corners: Vector3[];
|
||||||
|
color: Color4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Babylon side of the page: cloth mesh, obstacles, contour lines and BVH boxes. The solver never touches it. */
|
||||||
|
export class ClothIcmRenderer {
|
||||||
|
private scene: Scene | null = null;
|
||||||
|
private clothMesh: Mesh | null = null;
|
||||||
|
private clothMaterial: ShaderMaterial | null = null;
|
||||||
|
private obstacleMaterial: StandardMaterial | null = null;
|
||||||
|
private obstacles: Mesh[] = [];
|
||||||
|
private contourLines: LinesMesh | null = null;
|
||||||
|
private bvhLines: LinesMesh | null = null;
|
||||||
|
private vertexBuffer = new Float32Array(0);
|
||||||
|
private culledRegion = new Uint8Array(0);
|
||||||
|
private bvhBoxes: BvhBox[] = [];
|
||||||
|
private bvhPaths: Vector3[][] = [];
|
||||||
|
private bvhPathColors: Color4[][] = [];
|
||||||
|
private bvhShownDepth = -1;
|
||||||
|
|
||||||
|
init(scene: Scene, topology: ClothTopology, sceneDef: ClothIcmScene): void {
|
||||||
|
this.dispose();
|
||||||
|
this.scene = scene;
|
||||||
|
this.vertexBuffer = new Float32Array(topology.vertexCount * 3);
|
||||||
|
this.createClothMesh(scene, topology);
|
||||||
|
this.createObstacles(scene, sceneDef);
|
||||||
|
this.setupCamera(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(solver: ClothIcmSolver, showBvh: boolean, bvhDepth: number): void {
|
||||||
|
if (!this.scene || !this.clothMesh) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.updateClothPositions(solver.positions);
|
||||||
|
this.updateContourLines(this.scene, solver.intersections, solver.contours);
|
||||||
|
this.updateBvhLines(this.scene, solver.bvh, showBvh, bvhDepth);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.contourLines?.dispose();
|
||||||
|
this.bvhLines?.dispose();
|
||||||
|
this.clothMesh?.dispose();
|
||||||
|
this.clothMaterial?.dispose();
|
||||||
|
this.obstacles.forEach(mesh => mesh.dispose());
|
||||||
|
this.obstacleMaterial?.dispose();
|
||||||
|
this.contourLines = null;
|
||||||
|
this.bvhLines = null;
|
||||||
|
this.bvhBoxes = [];
|
||||||
|
this.bvhPaths = [];
|
||||||
|
this.bvhPathColors = [];
|
||||||
|
this.clothMesh = null;
|
||||||
|
this.clothMaterial = null;
|
||||||
|
this.obstacles = [];
|
||||||
|
this.obstacleMaterial = null;
|
||||||
|
this.scene = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createClothMesh(scene: Scene, topology: ClothTopology): void {
|
||||||
|
const {gridWidth, gridHeight, vertexCount} = topology;
|
||||||
|
const uvs = new Float32Array(vertexCount * 2);
|
||||||
|
for (let vertex = 0; vertex < vertexCount; vertex++) {
|
||||||
|
uvs[vertex * 2] = (vertex % gridWidth) / (gridWidth - 1);
|
||||||
|
uvs[vertex * 2 + 1] = 1 - Math.floor(vertex / gridWidth) / (gridHeight - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vertexData = new VertexData();
|
||||||
|
vertexData.positions = new Float32Array(vertexCount * 3);
|
||||||
|
vertexData.indices = topology.triangles;
|
||||||
|
vertexData.uvs = uvs;
|
||||||
|
const mesh = new Mesh('clothIcm', scene);
|
||||||
|
vertexData.applyToMesh(mesh, true);
|
||||||
|
// The bounding box is not refit when the positions change.
|
||||||
|
mesh.alwaysSelectAsActiveMesh = true;
|
||||||
|
|
||||||
|
const material = new ShaderMaterial('clothIcmMat', scene, {
|
||||||
|
vertexSource: CLOTH_VERTEX_SHADER_GLSL,
|
||||||
|
fragmentSource: CLOTH_FRAGMENT_SHADER_GLSL
|
||||||
|
}, {
|
||||||
|
attributes: ['position', 'uv'],
|
||||||
|
uniforms: ['viewProjection']
|
||||||
|
});
|
||||||
|
material.backFaceCulling = false;
|
||||||
|
mesh.material = material;
|
||||||
|
this.clothMesh = mesh;
|
||||||
|
this.clothMaterial = material;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The scene has no light, so the obstacles are unlit wireframes. */
|
||||||
|
private createObstacles(scene: Scene, sceneDef: ClothIcmScene): void {
|
||||||
|
const material = new StandardMaterial('clothIcmObstacle', scene);
|
||||||
|
material.wireframe = true;
|
||||||
|
material.disableLighting = true;
|
||||||
|
material.emissiveColor = OBSTACLE_COLOR;
|
||||||
|
this.obstacleMaterial = material;
|
||||||
|
|
||||||
|
const {sphere, groundY} = sceneDef;
|
||||||
|
if (sphere) {
|
||||||
|
const mesh = MeshBuilder.CreateSphere('clothIcmSphere', {diameter: sphere.r * 2, segments: 24}, scene);
|
||||||
|
mesh.position.set(sphere.x, sphere.y, sphere.z);
|
||||||
|
mesh.material = material;
|
||||||
|
this.obstacles.push(mesh);
|
||||||
|
}
|
||||||
|
if (groundY !== null) {
|
||||||
|
const mesh = MeshBuilder.CreateGround('clothIcmGround', {width: GROUND_SIZE, height: GROUND_SIZE, subdivisions: 16}, scene);
|
||||||
|
mesh.position.y = groundY;
|
||||||
|
mesh.material = material;
|
||||||
|
this.obstacles.push(mesh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupCamera(scene: Scene): void {
|
||||||
|
const camera = scene.activeCamera as ArcRotateCamera | null;
|
||||||
|
if (camera) {
|
||||||
|
camera.alpha = Math.PI / 4;
|
||||||
|
camera.beta = Math.PI / 2.5;
|
||||||
|
camera.radius = 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateClothPositions(positions: Float32Array): void {
|
||||||
|
const buffer = this.vertexBuffer;
|
||||||
|
for (let vertex = 0; vertex < buffer.length / 3; vertex++) {
|
||||||
|
buffer[vertex * 3] = positions[vertex * 4];
|
||||||
|
buffer[vertex * 3 + 1] = positions[vertex * 4 + 1];
|
||||||
|
buffer[vertex * 3 + 2] = positions[vertex * 4 + 2];
|
||||||
|
}
|
||||||
|
this.clothMesh?.updateVerticesData(VertexBuffer.PositionKind, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hit points come from the detection, which runs on the predicted positions before the solver moves the cloth,
|
||||||
|
* so the lines can be a little off the drawn cloth (up to about 0.05 m).
|
||||||
|
* The mesh is created once and gets new geometry every frame. Disposing and creating it again would drop its shader
|
||||||
|
* (reference counted) and recompile it; the line shader compiles asynchronously, so a mesh whose point layout changes
|
||||||
|
* every frame (the drop scene) was never ready to draw.
|
||||||
|
*/
|
||||||
|
private updateContourLines(scene: Scene, set: IntersectionSet, contours: Contour[]): void {
|
||||||
|
if (contours.length === 0) {
|
||||||
|
this.contourLines?.setEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vertexData = CreateLineSystemVertexData({lines: contours.map(contour => this.contourPath(set, contour))});
|
||||||
|
if (!this.contourLines) {
|
||||||
|
const mesh = new LinesMesh('clothIcmContours', scene);
|
||||||
|
mesh.color = CONTOUR_COLOR;
|
||||||
|
mesh.renderingGroupId = OVERLAY_RENDERING_GROUP;
|
||||||
|
mesh.alwaysSelectAsActiveMesh = true;
|
||||||
|
this.contourLines = mesh;
|
||||||
|
}
|
||||||
|
vertexData.applyToMesh(this.contourLines, true);
|
||||||
|
this.contourLines.setEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private contourPath(set: IntersectionSet, contour: Contour): Vector3[] {
|
||||||
|
const path = contour.points.map(hit => new Vector3(set.points[hit * 3], set.points[hit * 3 + 1], set.points[hit * 3 + 2]));
|
||||||
|
if (contour.closed) {
|
||||||
|
path.push(path[0].clone());
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The boxes are built once per shown level; every frame only their corners and colors are rewritten in place. */
|
||||||
|
private updateBvhLines(scene: Scene, bvh: ClothBvh, showBvh: boolean, depth: number): void {
|
||||||
|
if (!showBvh) {
|
||||||
|
this.bvhLines?.dispose();
|
||||||
|
this.bvhLines = null;
|
||||||
|
this.bvhBoxes = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.markCulledRegions(bvh);
|
||||||
|
if (!this.bvhLines || this.bvhShownDepth !== depth) {
|
||||||
|
this.rebuildBvhLines(scene, bvh, depth);
|
||||||
|
}
|
||||||
|
for (const box of this.bvhBoxes) {
|
||||||
|
box.color.copyFrom(this.culledRegion[box.node] === 1 ? CULLED_COLOR : TESTED_COLOR);
|
||||||
|
this.moveBox(bvh, box);
|
||||||
|
}
|
||||||
|
if (this.bvhLines) {
|
||||||
|
MeshBuilder.CreateLineSystem('clothIcmBvh', {lines: this.bvhPaths, colors: this.bvhPathColors, instance: this.bvhLines});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuildBvhLines(scene: Scene, bvh: ClothBvh, depth: number): void {
|
||||||
|
this.bvhLines?.dispose();
|
||||||
|
this.bvhLines = null;
|
||||||
|
this.bvhBoxes = [];
|
||||||
|
this.bvhPaths = [];
|
||||||
|
this.bvhPathColors = [];
|
||||||
|
this.bvhShownDepth = depth;
|
||||||
|
for (let node = 0; node < bvh.nodeCount; node++) {
|
||||||
|
const isShown = bvh.depth[node] === depth || (bvh.depth[node] < depth && bvh.isLeaf(node));
|
||||||
|
if (isShown) {
|
||||||
|
this.addBox(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.bvhPaths.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mesh = MeshBuilder.CreateLineSystem('clothIcmBvh', {lines: this.bvhPaths, colors: this.bvhPathColors, updatable: true}, scene);
|
||||||
|
mesh.renderingGroupId = OVERLAY_RENDERING_GROUP;
|
||||||
|
mesh.alwaysSelectAsActiveMesh = true;
|
||||||
|
this.bvhLines = mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A node is marked when its own self-test or the one of an ancestor was culled. Parents have smaller ids than children. */
|
||||||
|
private markCulledRegions(bvh: ClothBvh): void {
|
||||||
|
if (this.culledRegion.length !== bvh.nodeCount) {
|
||||||
|
this.culledRegion = new Uint8Array(bvh.nodeCount);
|
||||||
|
}
|
||||||
|
for (let node = 0; node < bvh.nodeCount; node++) {
|
||||||
|
const parent = bvh.parent[node];
|
||||||
|
const parentCulled = parent >= 0 ? this.culledRegion[parent] : 0;
|
||||||
|
this.culledRegion[node] = parentCulled | bvh.culled[node];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A box is 6 polylines over 8 shared corners (bottom 0-3, top 4-7); all share one color object. */
|
||||||
|
private addBox(node: number): void {
|
||||||
|
const corners = Array.from({length: 8}, () => new Vector3());
|
||||||
|
const color = new Color4();
|
||||||
|
const bottom = corners.slice(0, 4);
|
||||||
|
const top = corners.slice(4);
|
||||||
|
const paths = [
|
||||||
|
[...bottom, bottom[0]],
|
||||||
|
[...top, top[0]],
|
||||||
|
...bottom.map((corner, index) => [corner, top[index]])
|
||||||
|
];
|
||||||
|
for (const path of paths) {
|
||||||
|
this.bvhPaths.push(path);
|
||||||
|
this.bvhPathColors.push(path.map(() => color));
|
||||||
|
}
|
||||||
|
this.bvhBoxes.push({node, corners, color});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Axis-aligned part of the 18-DOP (axes 0 to 2). */
|
||||||
|
private moveBox(bvh: ClothBvh, box: BvhBox): void {
|
||||||
|
const base = box.node * DOP_SIZE;
|
||||||
|
const {bounds} = bvh;
|
||||||
|
const {corners} = box;
|
||||||
|
const minX = bounds[base];
|
||||||
|
const minY = bounds[base + 1];
|
||||||
|
const minZ = bounds[base + 2];
|
||||||
|
const maxX = bounds[base + 9];
|
||||||
|
const maxY = bounds[base + 10];
|
||||||
|
const maxZ = bounds[base + 11];
|
||||||
|
for (let index = 0; index < 4; index++) {
|
||||||
|
const x = index === 1 || index === 2 ? maxX : minX;
|
||||||
|
const z = index >= 2 ? maxZ : minZ;
|
||||||
|
corners[index].set(x, minY, z);
|
||||||
|
corners[index + 4].set(x, maxY, z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {ClothBvh} from './collision/cloth-bvh';
|
||||||
|
import {detectCollisions} from './collision/collision-detection';
|
||||||
|
import {buildClothTopology} from './collision/cloth-topology';
|
||||||
|
import {IntersectionSet} from './collision/intersection-set';
|
||||||
|
import {ClothIcmSceneId} from './cloth-icm.model';
|
||||||
|
import {applyContactFriction, initialPositions, projectObstacles, sceneOf} from './cloth-icm-scenes';
|
||||||
|
|
||||||
|
function intersectionsAtStart(id: ClothIcmSceneId): number {
|
||||||
|
const topology = buildClothTopology(30, 30);
|
||||||
|
const positions = initialPositions(id, topology, 0.1, 1);
|
||||||
|
const bvh = new ClothBvh(topology);
|
||||||
|
bvh.refit(positions);
|
||||||
|
const set = new IntersectionSet();
|
||||||
|
detectCollisions(bvh, positions, topology, 0, set, []);
|
||||||
|
return set.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cloth icm scenes', () => {
|
||||||
|
it('starts the tangled scene with intersections', () => {
|
||||||
|
expect(intersectionsAtStart('tangled')).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the drop scene without intersections', () => {
|
||||||
|
expect(intersectionsAtStart('drop')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pins only the top row of the tangled scene', () => {
|
||||||
|
const topology = buildClothTopology(6, 6);
|
||||||
|
const positions = initialPositions('tangled', topology, 0.1, 2);
|
||||||
|
const pinned = Array.from({length: topology.vertexCount}, (_, vertex) => positions[vertex * 4 + 3] === 0);
|
||||||
|
expect(pinned.filter(Boolean).length).toBe(6);
|
||||||
|
expect(pinned.slice(0, 6).every(Boolean)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushes vertices out of the sphere and above the ground', () => {
|
||||||
|
const scene = sceneOf('drop');
|
||||||
|
const sphere = scene.sphere!;
|
||||||
|
const groundY = scene.groundY!;
|
||||||
|
const positions = new Float32Array([sphere.x, sphere.y + 0.1, sphere.z, 1, 0, groundY - 10, 5, 1, 0, groundY + 3, 5, 1]);
|
||||||
|
projectObstacles(positions, scene, 0.1);
|
||||||
|
const distance = Math.hypot(positions[0] - sphere.x, positions[1] - sphere.y, positions[2] - sphere.z);
|
||||||
|
expect(distance).toBeCloseTo(sphere.r + 0.1);
|
||||||
|
expect(positions[5]).toBeCloseTo(groundY + 0.1);
|
||||||
|
expect(positions[9]).toBe(groundY + 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes tangential motion of a vertex that rests on the ground', () => {
|
||||||
|
const scene = sceneOf('drop');
|
||||||
|
const groundY = scene.groundY!;
|
||||||
|
const previous = new Float32Array([0, groundY + 0.1, 0, 1]);
|
||||||
|
const positions = new Float32Array([1, groundY + 0.1, 2, 1]);
|
||||||
|
applyContactFriction(positions, previous, scene, 0.1, 0.5);
|
||||||
|
expect(positions[0]).toBeCloseTo(0.5);
|
||||||
|
expect(positions[2]).toBeCloseTo(1);
|
||||||
|
expect(positions[1]).toBeCloseTo(groundY + 0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a vertex in the air alone when friction is applied', () => {
|
||||||
|
const scene = sceneOf('drop');
|
||||||
|
const previous = new Float32Array([0, 3, 0, 1]);
|
||||||
|
const positions = new Float32Array([1, 3, 2, 1]);
|
||||||
|
applyContactFriction(positions, previous, scene, 0.1, 0.5);
|
||||||
|
expect(Array.from(positions)).toEqual([1, 3, 2, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves pinned vertices alone', () => {
|
||||||
|
const positions = new Float32Array([0, 1, 0, 0]);
|
||||||
|
projectObstacles(positions, sceneOf('drop'), 0.1);
|
||||||
|
expect(positions[1]).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import {ClothIcmSceneId} from './cloth-icm.model';
|
||||||
|
import {ClothTopology} from './collision/cloth-topology';
|
||||||
|
|
||||||
|
interface Sphere {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
r: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClothIcmScene {
|
||||||
|
gravityY: number;
|
||||||
|
sphere: Sphere | null;
|
||||||
|
groundY: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCENES: Record<ClothIcmSceneId, ClothIcmScene> = {
|
||||||
|
tangled: {gravityY: 0, sphere: null, groundY: null},
|
||||||
|
drop: {gravityY: -9.81, sphere: {x: 0, y: -0.3, z: 0, r: 0.7}, groundY: -1}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DROP_HEIGHT = 0.9;
|
||||||
|
// The back half hangs one spacing behind the front half, so the fold edges have their rest length.
|
||||||
|
const FOLD_GAP_IN_SPACINGS = 1;
|
||||||
|
const BUMP_HEIGHT_IN_SPACINGS = 1.5;
|
||||||
|
const BUMP_RADIUS_IN_SPACINGS = 8;
|
||||||
|
const BUMP_MARGIN_IN_SPACINGS = 2;
|
||||||
|
// Keeps the back half off the grid lines of the front half; hits through a vertex or an edge are dropped by the detection.
|
||||||
|
const BACK_OFFSET_X_IN_SPACINGS = 0.13;
|
||||||
|
const BACK_OFFSET_Y_IN_SPACINGS = 0.07;
|
||||||
|
|
||||||
|
export function sceneOf(id: ClothIcmSceneId): ClothIcmScene {
|
||||||
|
return SCENES[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vec4 layout [x, y, z, invMass]. */
|
||||||
|
export function initialPositions(id: ClothIcmSceneId, topology: ClothTopology, spacing: number, invMass: number): Float32Array {
|
||||||
|
return id === 'tangled' ? tangledPositions(topology, spacing, invMass) : dropPositions(topology, spacing, invMass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hangs from the pinned top row. The rows below the middle are folded back up behind the front half
|
||||||
|
* and a ridge across the back half pokes through the front half: open contours from border to border at frame 0.
|
||||||
|
*/
|
||||||
|
function tangledPositions(topology: ClothTopology, spacing: number, invMass: number): Float32Array {
|
||||||
|
const {gridWidth, gridHeight} = topology;
|
||||||
|
const positions = new Float32Array(topology.vertexCount * 4);
|
||||||
|
const frontRows = Math.floor(gridHeight / 2);
|
||||||
|
const top = ((gridHeight - 1) * spacing) / 2;
|
||||||
|
const frontEndY = top - (frontRows - 1) * spacing;
|
||||||
|
const halfBackHeight = ((gridHeight - frontRows - 1) * spacing) / 2;
|
||||||
|
const backCenterY = frontEndY + halfBackHeight;
|
||||||
|
// Small grids need a narrower ridge so that it stays inside the back half.
|
||||||
|
const ridgeRadius = Math.min(BUMP_RADIUS_IN_SPACINGS * spacing, halfBackHeight - BUMP_MARGIN_IN_SPACINGS * spacing);
|
||||||
|
|
||||||
|
const profile = bumpProfile(BUMP_HEIGHT_IN_SPACINGS * spacing, ridgeRadius);
|
||||||
|
for (let row = 0; row < gridHeight; row++) {
|
||||||
|
for (let column = 0; column < gridWidth; column++) {
|
||||||
|
const base = (row * gridWidth + column) * 4;
|
||||||
|
const isFront = row < frontRows;
|
||||||
|
const x = (column - (gridWidth - 1) / 2 + (isFront ? 0 : BACK_OFFSET_X_IN_SPACINGS)) * spacing;
|
||||||
|
let y = top - row * spacing;
|
||||||
|
let z = 0;
|
||||||
|
if (!isFront) {
|
||||||
|
y = frontEndY + (row - frontRows + BACK_OFFSET_Y_IN_SPACINGS) * spacing;
|
||||||
|
const [ridgeY, ridgeZ] = ridgePoint(profile, y - backCenterY);
|
||||||
|
y = backCenterY + ridgeY;
|
||||||
|
z = -FOLD_GAP_IN_SPACINGS * spacing + ridgeZ;
|
||||||
|
}
|
||||||
|
positions[base] = x;
|
||||||
|
positions[base + 1] = y;
|
||||||
|
positions[base + 2] = z;
|
||||||
|
positions[base + 3] = row === 0 ? 0 : invMass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BumpProfile {
|
||||||
|
amplitude: number;
|
||||||
|
radius: number;
|
||||||
|
/** Arc length of the profile from the center to radius `i * radius / BUMP_TABLE_STEPS`. */
|
||||||
|
arcLength: Float64Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUMP_TABLE_STEPS = 512;
|
||||||
|
|
||||||
|
/** Smooth bump with compact support: exactly flat beyond `radius`. */
|
||||||
|
function bumpHeight(distance: number, amplitude: number, radius: number): number {
|
||||||
|
const falloff = Math.max(0, 1 - (distance * distance) / (radius * radius));
|
||||||
|
return amplitude * falloff ** 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpProfile(amplitude: number, radius: number): BumpProfile {
|
||||||
|
const arcLength = new Float64Array(BUMP_TABLE_STEPS + 1);
|
||||||
|
const step = radius / BUMP_TABLE_STEPS;
|
||||||
|
for (let index = 1; index <= BUMP_TABLE_STEPS; index++) {
|
||||||
|
const rise = bumpHeight(index * step, amplitude, radius) - bumpHeight((index - 1) * step, amplitude, radius);
|
||||||
|
arcLength[index] = arcLength[index - 1] + Math.hypot(step, rise);
|
||||||
|
}
|
||||||
|
return {amplitude, radius, arcLength};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bends the flat offset `u` (across the ridge, from its center line) into the ridge profile. The profile radius with
|
||||||
|
* the same arc length as `u` is used, so edges across the ridge keep their rest length, and edges along it are
|
||||||
|
* untouched. A ridge is a developable shape: no edge is stretched, the cloth has no stress that could flatten it.
|
||||||
|
* The flat rest of the sheet is shifted inward by the extra arc length.
|
||||||
|
*/
|
||||||
|
function ridgePoint(profile: BumpProfile, u: number): [number, number] {
|
||||||
|
const {arcLength, radius, amplitude} = profile;
|
||||||
|
const flatDistance = Math.abs(u);
|
||||||
|
const total = arcLength[BUMP_TABLE_STEPS];
|
||||||
|
let projected: number;
|
||||||
|
if (flatDistance >= total) {
|
||||||
|
projected = radius + (flatDistance - total);
|
||||||
|
} else {
|
||||||
|
let index = 1;
|
||||||
|
while (arcLength[index] < flatDistance) {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
const share = (flatDistance - arcLength[index - 1]) / (arcLength[index] - arcLength[index - 1]);
|
||||||
|
projected = ((index - 1 + share) * radius) / BUMP_TABLE_STEPS;
|
||||||
|
}
|
||||||
|
return [Math.sign(u) * projected, bumpHeight(projected, amplitude, radius)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropPositions(topology: ClothTopology, spacing: number, invMass: number): Float32Array {
|
||||||
|
const {gridWidth, gridHeight} = topology;
|
||||||
|
const positions = new Float32Array(topology.vertexCount * 4);
|
||||||
|
for (let row = 0; row < gridHeight; row++) {
|
||||||
|
for (let column = 0; column < gridWidth; column++) {
|
||||||
|
const base = (row * gridWidth + column) * 4;
|
||||||
|
positions[base] = (column - (gridWidth - 1) / 2) * spacing;
|
||||||
|
positions[base + 1] = DROP_HEIGHT;
|
||||||
|
positions[base + 2] = (row - (gridHeight - 1) / 2) * spacing;
|
||||||
|
positions[base + 3] = invMass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pushes free vertices out of the sphere and above the ground, both fattened by `thickness`. */
|
||||||
|
export function projectObstacles(positions: Float32Array, scene: ClothIcmScene, thickness: number): void {
|
||||||
|
const vertexCount = positions.length / 4;
|
||||||
|
const {sphere, groundY} = scene;
|
||||||
|
for (let vertex = 0; vertex < vertexCount; vertex++) {
|
||||||
|
const base = vertex * 4;
|
||||||
|
if (positions[base + 3] <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sphere) {
|
||||||
|
pushOutOfSphere(positions, base, sphere, thickness);
|
||||||
|
}
|
||||||
|
if (groundY !== null && positions[base + 1] < groundY + thickness) {
|
||||||
|
positions[base + 1] = groundY + thickness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushOutOfSphere(positions: Float32Array, base: number, sphere: Sphere, thickness: number): void {
|
||||||
|
const dx = positions[base] - sphere.x;
|
||||||
|
const dy = positions[base + 1] - sphere.y;
|
||||||
|
const dz = positions[base + 2] - sphere.z;
|
||||||
|
const distance = Math.hypot(dx, dy, dz);
|
||||||
|
const radius = sphere.r + thickness;
|
||||||
|
if (distance >= radius || distance < 1e-9) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scale = radius / distance;
|
||||||
|
positions[base] = sphere.x + dx * scale;
|
||||||
|
positions[base + 1] = sphere.y + dy * scale;
|
||||||
|
positions[base + 2] = sphere.z + dz * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTACT_TOLERANCE = 1e-4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a share of the tangential motion of the vertices that rest on an obstacle. Without it the cloth slides
|
||||||
|
* off the sphere without any resistance and piles up on the ground, where the folds cannot be resolved.
|
||||||
|
*/
|
||||||
|
export function applyContactFriction(positions: Float32Array, previous: Float32Array, scene: ClothIcmScene, thickness: number, friction: number): void {
|
||||||
|
const vertexCount = positions.length / 4;
|
||||||
|
const {sphere, groundY} = scene;
|
||||||
|
for (let vertex = 0; vertex < vertexCount; vertex++) {
|
||||||
|
const base = vertex * 4;
|
||||||
|
if (positions[base + 3] <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (groundY !== null && positions[base + 1] <= groundY + thickness + CONTACT_TOLERANCE) {
|
||||||
|
positions[base] -= friction * (positions[base] - previous[base]);
|
||||||
|
positions[base + 2] -= friction * (positions[base + 2] - previous[base + 2]);
|
||||||
|
}
|
||||||
|
if (sphere) {
|
||||||
|
slowOnSphere(positions, previous, base, sphere, thickness, friction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slowOnSphere(
|
||||||
|
positions: Float32Array, previous: Float32Array, base: number, sphere: Sphere,
|
||||||
|
thickness: number, friction: number
|
||||||
|
): void {
|
||||||
|
const nx = positions[base] - sphere.x;
|
||||||
|
const ny = positions[base + 1] - sphere.y;
|
||||||
|
const nz = positions[base + 2] - sphere.z;
|
||||||
|
const distance = Math.hypot(nx, ny, nz);
|
||||||
|
if (distance > sphere.r + thickness + CONTACT_TOLERANCE || distance < 1e-9) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dx = positions[base] - previous[base];
|
||||||
|
const dy = positions[base + 1] - previous[base + 1];
|
||||||
|
const dz = positions[base + 2] - previous[base + 2];
|
||||||
|
const normalPart = (dx * nx + dy * ny + dz * nz) / (distance * distance);
|
||||||
|
positions[base] -= friction * (dx - normalPart * nx);
|
||||||
|
positions[base + 1] -= friction * (dy - normalPart * ny);
|
||||||
|
positions[base + 2] -= friction * (dz - normalPart * nz);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import {ClothIcmConfig, ClothIcmSceneId, SCENE_SETTINGS} from './cloth-icm.model';
|
||||||
|
import {ClothIcmSolver} from './cloth-icm-solver';
|
||||||
|
|
||||||
|
const DT = 1 / 60;
|
||||||
|
const TANGLED_SPACING = 0.1;
|
||||||
|
|
||||||
|
function config(gridSize: number, thickness = 0.01, proximityRedetectEvery = 0, spacing = TANGLED_SPACING): ClothIcmConfig {
|
||||||
|
return {gridWidth: gridSize, gridHeight: gridSize, spacing, particleInvMass: 1 / (spacing * spacing), thickness, proximityRedetectEvery};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mean hits per frame over frames 100..240 and the share of those frames with 0 hits, drop scene with the page settings. */
|
||||||
|
function dropHitsAfterLanding(icmEnabled: boolean, proximityRedetectEvery: number): {average: number; zeroShare: number} {
|
||||||
|
const {gridSize, spacing, thickness} = SCENE_SETTINGS.drop;
|
||||||
|
const solver = new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery, spacing), 'drop');
|
||||||
|
const first = 100;
|
||||||
|
const last = 240;
|
||||||
|
let sum = 0;
|
||||||
|
let zeroFrames = 0;
|
||||||
|
for (let frame = 1; frame <= last; frame++) {
|
||||||
|
solver.step({dt: DT, icmEnabled, icmStep: 0.25});
|
||||||
|
if (frame > first) {
|
||||||
|
sum += solver.stats.intersections;
|
||||||
|
zeroFrames += solver.stats.intersections === 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {average: sum / (last - first), zeroShare: zeroFrames / (last - first)};
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(
|
||||||
|
sceneId: ClothIcmSceneId, gridSize: number, frames: number, icmEnabled: boolean, thickness?: number, proximityRedetectEvery?: number
|
||||||
|
): {solver: ClothIcmSolver; firstContours: number} {
|
||||||
|
const solver = new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery), sceneId);
|
||||||
|
let firstContours = 0;
|
||||||
|
for (let frame = 0; frame < frames; frame++) {
|
||||||
|
solver.step({dt: DT, icmEnabled, icmStep: 0.25});
|
||||||
|
if (frame === 0) {
|
||||||
|
firstContours = solver.contours.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {solver, firstContours};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFrames(solver: ClothIcmSolver, frames: number, icmStep: number, dt: number, icmEnabled = true): number {
|
||||||
|
for (let frame = 0; frame < frames; frame++) {
|
||||||
|
solver.step({dt, icmEnabled, icmStep});
|
||||||
|
}
|
||||||
|
return solver.stats.intersections;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ClothIcmSolver', () => {
|
||||||
|
it('untangles the tangled scene with ICM on', () => {
|
||||||
|
const {solver, firstContours} = run('tangled', 30, 150, true);
|
||||||
|
expect(firstContours).toBeGreaterThan(0);
|
||||||
|
expect(solver.contours.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the tangled scene tangled with ICM off', () => {
|
||||||
|
const {solver, firstContours} = run('tangled', 30, 150, false);
|
||||||
|
expect(firstContours).toBeGreaterThan(0);
|
||||||
|
expect(solver.contours.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the dropped cloth finite and above the ground', () => {
|
||||||
|
const {gridSize, spacing, thickness, proximityRedetectEvery} = SCENE_SETTINGS.drop;
|
||||||
|
const solver = new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery, spacing), 'drop');
|
||||||
|
runFrames(solver, 300, 0.25, DT);
|
||||||
|
const groundY = solver.scene.groundY ?? -Infinity;
|
||||||
|
for (let vertex = 0; vertex < solver.topology.vertexCount; vertex++) {
|
||||||
|
const y = solver.positions[vertex * 4 + 1];
|
||||||
|
expect(Number.isFinite(y)).toBe(true);
|
||||||
|
expect(y).toBeGreaterThanOrEqual(groundY);
|
||||||
|
}
|
||||||
|
expect(solver.positions.every(Number.isFinite)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('drop scene after landing', () => {
|
||||||
|
const {proximityRedetectEvery} = SCENE_SETTINGS.drop;
|
||||||
|
const icmOn = dropHitsAfterLanding(true, proximityRedetectEvery);
|
||||||
|
|
||||||
|
it('keeps the dropped page grid almost free of intersections', () => {
|
||||||
|
expect(icmOn.average).toBeLessThanOrEqual(5);
|
||||||
|
expect(icmOn.zeroShare).toBeGreaterThanOrEqual(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the ICM effect', () => {
|
||||||
|
expect(dropHitsAfterLanding(false, proximityRedetectEvery).average).toBeGreaterThanOrEqual(3 * icmOn.average);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-detects proximity inside the frame', () => {
|
||||||
|
expect(proximityRedetectEvery).toBeGreaterThan(0);
|
||||||
|
expect(dropHitsAfterLanding(true, 0).average).toBeGreaterThanOrEqual(3 * icmOn.average);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the BVH culled flags of the frame-start detection', () => {
|
||||||
|
const {gridSize, spacing, thickness, proximityRedetectEvery} = SCENE_SETTINGS.drop;
|
||||||
|
const solver = new ClothIcmSolver(config(gridSize, thickness, proximityRedetectEvery, spacing), 'drop');
|
||||||
|
let framesWithMismatch = 0;
|
||||||
|
for (let frame = 0; frame < 60; frame++) {
|
||||||
|
solver.step({dt: DT, icmEnabled: true, icmStep: 0.25});
|
||||||
|
const flaggedNodes = solver.bvh.culled.reduce((sum, flag) => sum + flag, 0);
|
||||||
|
if (flaggedNodes !== solver.stats.culledNodes) {
|
||||||
|
framesWithMismatch++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(framesWithMismatch).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports collision stats', () => {
|
||||||
|
const {solver} = run('tangled', 30, 1, true);
|
||||||
|
expect(solver.stats.intersections).toBeGreaterThan(0);
|
||||||
|
expect(solver.stats.testedPairs).toBeGreaterThan(0);
|
||||||
|
expect(solver.stats.collisionMs).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([0.05, 0.3, 0.5])('resolves the tangled page grid at icmStep %s', icmStep => {
|
||||||
|
const solver = new ClothIcmSolver(config(50), 'tangled');
|
||||||
|
expect(runFrames(solver, 1, icmStep, DT)).toBeGreaterThan(0);
|
||||||
|
runFrames(solver, 299, icmStep, DT);
|
||||||
|
expect(solver.stats.intersections).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the tangled page grid unchanged at rest with ICM off', () => {
|
||||||
|
const solver = new ClothIcmSolver(config(50), 'tangled');
|
||||||
|
const start = runFrames(solver, 1, 0.25, DT, false);
|
||||||
|
runFrames(solver, 299, 0.25, DT, false);
|
||||||
|
expect(solver.stats.intersections).toBe(start);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the same result at 144 fps as at 60 fps', () => {
|
||||||
|
const solver = new ClothIcmSolver(config(50), 'tangled');
|
||||||
|
runFrames(solver, 360, 0.25, 1 / 144);
|
||||||
|
expect(solver.stats.intersections).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never moves the pinned top row', () => {
|
||||||
|
const {solver} = run('tangled', 30, 30, true);
|
||||||
|
const initial = new ClothIcmSolver(config(30), 'tangled');
|
||||||
|
for (let vertex = 0; vertex < 30; vertex++) {
|
||||||
|
for (let axis = 0; axis < 3; axis++) {
|
||||||
|
expect(solver.positions[vertex * 4 + axis]).toBe(initial.positions[vertex * 4 + axis]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import {ClothPhysicsParams, solveConstraints, updateVelocities} from '../cloth/strategies/cloth-cpu-physics';
|
||||||
|
import {
|
||||||
|
CONTACT_FRICTION, ClothIcmConfig, ClothIcmSceneId, ClothIcmStats, DISTANCE_COMPLIANCE, emptyClothIcmStats, ICM_G0, ICM_SUBSTEPS, MAX_DT, MAX_SPEED, SIM_DT, VELOCITY_DAMPING
|
||||||
|
} from './cloth-icm.model';
|
||||||
|
import {applyContactFriction, ClothIcmScene, initialPositions, projectObstacles, sceneOf} from './cloth-icm-scenes';
|
||||||
|
import {ClothBvh} from './collision/cloth-bvh';
|
||||||
|
import {buildClothTopology, buildDistanceConstraints, ClothTopology} from './collision/cloth-topology';
|
||||||
|
import {detectCollisions} from './collision/collision-detection';
|
||||||
|
import {Contour, traceContours} from './collision/intersection-contours';
|
||||||
|
import {IntersectionSet} from './collision/intersection-set';
|
||||||
|
import {buildProximityConstraints, solveProximityConstraints} from './collision/proximity-constraints';
|
||||||
|
import {accumulateIcmDisplacement, applyAveragedDisplacement} from './response/icm-response';
|
||||||
|
|
||||||
|
const STEP_TOLERANCE = 1e-9;
|
||||||
|
|
||||||
|
export interface ClothIcmFrameParams {
|
||||||
|
dt: number;
|
||||||
|
icmEnabled: boolean;
|
||||||
|
/** Maximum ICM displacement per frame as a fraction of the grid spacing. */
|
||||||
|
icmStep: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CPU cloth with self-collision handling. Pure TypeScript, no Babylon: one `step()` per frame. */
|
||||||
|
export class ClothIcmSolver {
|
||||||
|
readonly topology: ClothTopology;
|
||||||
|
/** Frame-start detection; the renderer reads its culled flags and bounds. */
|
||||||
|
readonly bvh: ClothBvh;
|
||||||
|
readonly scene: ClothIcmScene;
|
||||||
|
/** Vec4 layout [x, y, z, invMass]. */
|
||||||
|
readonly positions: Float32Array;
|
||||||
|
readonly intersections = new IntersectionSet();
|
||||||
|
contours: Contour[] = [];
|
||||||
|
stats: ClothIcmStats = emptyClothIcmStats();
|
||||||
|
|
||||||
|
private readonly prevPositions: Float32Array;
|
||||||
|
private readonly velocities: Float32Array;
|
||||||
|
private readonly distanceConstraints: Float32Array;
|
||||||
|
private readonly icmDelta: Float32Array;
|
||||||
|
private readonly icmCount: Float32Array;
|
||||||
|
/** Vertices with an ICM displacement this step, and their positions before the proximity solve. */
|
||||||
|
private readonly icmVertices: Int32Array;
|
||||||
|
private icmVertexCount = 0;
|
||||||
|
private readonly proximitySnapshot: Float32Array;
|
||||||
|
private accumulator = 0;
|
||||||
|
private readonly proximityCandidates: number[] = [];
|
||||||
|
private proximityConstraints: Float32Array = new Float32Array(0);
|
||||||
|
private readonly midFrameIntersections = new IntersectionSet();
|
||||||
|
private readonly midFrameBvh: ClothBvh;
|
||||||
|
private readonly physics: ClothPhysicsParams;
|
||||||
|
|
||||||
|
constructor(private readonly config: ClothIcmConfig, sceneId: ClothIcmSceneId) {
|
||||||
|
this.topology = buildClothTopology(config.gridWidth, config.gridHeight);
|
||||||
|
this.bvh = new ClothBvh(this.topology);
|
||||||
|
this.midFrameBvh = new ClothBvh(this.topology);
|
||||||
|
this.scene = sceneOf(sceneId);
|
||||||
|
const vertexCount = this.topology.vertexCount;
|
||||||
|
this.positions = initialPositions(sceneId, this.topology, config.spacing, config.particleInvMass);
|
||||||
|
this.prevPositions = this.positions.slice();
|
||||||
|
this.velocities = new Float32Array(vertexCount * 4);
|
||||||
|
this.distanceConstraints = buildDistanceConstraints(this.topology, config.spacing);
|
||||||
|
this.icmDelta = new Float32Array(vertexCount * 3);
|
||||||
|
this.icmCount = new Float32Array(vertexCount);
|
||||||
|
this.icmVertices = new Int32Array(vertexCount);
|
||||||
|
this.proximitySnapshot = new Float32Array(vertexCount * 3);
|
||||||
|
this.physics = {
|
||||||
|
dt: 0, gravityY: this.scene.gravityY, compliance: DISTANCE_COMPLIANCE, numVertices: vertexCount,
|
||||||
|
windX: 0, windY: 0, windZ: 0, time: 0, elongation: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advances by the frame time in steps of `SIM_DT`; a frame can run zero, one or two steps. */
|
||||||
|
step(params: ClothIcmFrameParams): void {
|
||||||
|
if (!(params.dt > 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.accumulator += Math.min(params.dt, MAX_DT);
|
||||||
|
while (this.accumulator >= SIM_DT - STEP_TOLERANCE) {
|
||||||
|
this.accumulator = Math.max(0, this.accumulator - SIM_DT);
|
||||||
|
this.simulateStep(params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private simulateStep(params: ClothIcmFrameParams): void {
|
||||||
|
this.physics.dt = SIM_DT;
|
||||||
|
this.predictPositions();
|
||||||
|
|
||||||
|
const collisionStart = performance.now();
|
||||||
|
const counters = this.detect(this.bvh, this.intersections);
|
||||||
|
this.contours = traceContours(this.intersections);
|
||||||
|
this.prepareIcm(params);
|
||||||
|
this.buildProximity();
|
||||||
|
const detectMs = performance.now() - collisionStart;
|
||||||
|
|
||||||
|
const redetectMs = this.solve();
|
||||||
|
const collisionMs = detectMs + redetectMs;
|
||||||
|
applyContactFriction(this.positions, this.prevPositions, this.scene, this.config.thickness, CONTACT_FRICTION);
|
||||||
|
updateVelocities(this.positions, this.prevPositions, this.velocities, this.physics);
|
||||||
|
this.limitSpeed();
|
||||||
|
|
||||||
|
this.stats = {
|
||||||
|
intersections: this.intersections.count,
|
||||||
|
contours: this.contours.length,
|
||||||
|
testedPairs: counters.testedPairs,
|
||||||
|
culledNodes: counters.culledNodes,
|
||||||
|
collisionMs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gravity and damping, then the predicted position. Written here and not taken from `integratePositions`:
|
||||||
|
* that one always adds a sine flutter force in y, even with the wind off.
|
||||||
|
*/
|
||||||
|
private predictPositions(): void {
|
||||||
|
const {dt, gravityY, numVertices} = this.physics;
|
||||||
|
const {velocities} = this;
|
||||||
|
for (let vertex = 0; vertex < numVertices; vertex++) {
|
||||||
|
const base = vertex * 4;
|
||||||
|
if (this.positions[base + 3] <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
velocities[base] *= VELOCITY_DAMPING;
|
||||||
|
velocities[base + 1] = velocities[base + 1] * VELOCITY_DAMPING + gravityY * dt;
|
||||||
|
velocities[base + 2] *= VELOCITY_DAMPING;
|
||||||
|
for (let axis = 0; axis < 3; axis++) {
|
||||||
|
this.prevPositions[base + axis] = this.positions[base + axis];
|
||||||
|
this.positions[base + axis] += velocities[base + axis] * dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private limitSpeed(): void {
|
||||||
|
const {velocities} = this;
|
||||||
|
for (let vertex = 0; vertex < this.physics.numVertices; vertex++) {
|
||||||
|
const base = vertex * 4;
|
||||||
|
const speed = Math.hypot(velocities[base], velocities[base + 1], velocities[base + 2]);
|
||||||
|
if (speed > MAX_SPEED) {
|
||||||
|
const scale = MAX_SPEED / speed;
|
||||||
|
velocities[base] *= scale;
|
||||||
|
velocities[base + 1] *= scale;
|
||||||
|
velocities[base + 2] *= scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private detect(bvh: ClothBvh, intersections: IntersectionSet): {testedPairs: number; culledNodes: number} {
|
||||||
|
bvh.refit(this.positions);
|
||||||
|
return detectCollisions(bvh, this.positions, this.topology, this.config.thickness, intersections, this.proximityCandidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildProximity(): void {
|
||||||
|
this.proximityConstraints = buildProximityConstraints(this.positions, this.topology, this.proximityCandidates, this.config.thickness);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the proximity constraints follow the current positions; the frame-start hits and the ICM displacement stay. */
|
||||||
|
private redetectProximity(): number {
|
||||||
|
const start = performance.now();
|
||||||
|
this.midFrameBvh.coneCullingEnabled = this.bvh.coneCullingEnabled;
|
||||||
|
this.detect(this.midFrameBvh, this.midFrameIntersections);
|
||||||
|
this.buildProximity();
|
||||||
|
return performance.now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareIcm(params: ClothIcmFrameParams): void {
|
||||||
|
this.icmDelta.fill(0);
|
||||||
|
this.icmCount.fill(0);
|
||||||
|
this.icmVertexCount = 0;
|
||||||
|
if (!params.icmEnabled || this.contours.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const icmParams = {h0: params.icmStep * this.config.spacing, g0: ICM_G0};
|
||||||
|
accumulateIcmDisplacement(
|
||||||
|
this.positions, this.topology, this.bvh.triangleNormals, this.intersections, this.contours, icmParams, this.icmDelta, this.icmCount
|
||||||
|
);
|
||||||
|
for (let vertex = 0; vertex < this.icmCount.length; vertex++) {
|
||||||
|
if (this.icmCount[vertex] > 0) {
|
||||||
|
this.icmVertices[this.icmVertexCount++] = vertex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ICM displacement is spread over the iterations so that the distance constraints can react to it. Returns the re-detection time. */
|
||||||
|
private solve(): number {
|
||||||
|
const icmScale = 1 / ICM_SUBSTEPS;
|
||||||
|
const every = this.config.proximityRedetectEvery;
|
||||||
|
let redetectMs = 0;
|
||||||
|
for (let iteration = 0; iteration < ICM_SUBSTEPS; iteration++) {
|
||||||
|
if (every > 0 && iteration > 0 && iteration % every === 0) {
|
||||||
|
redetectMs += this.redetectProximity();
|
||||||
|
}
|
||||||
|
solveConstraints(this.positions, this.distanceConstraints, this.physics);
|
||||||
|
this.solveProximityBesideIcm();
|
||||||
|
applyAveragedDisplacement(this.positions, this.icmDelta, this.icmCount, icmScale);
|
||||||
|
projectObstacles(this.positions, this.scene, this.config.thickness);
|
||||||
|
}
|
||||||
|
return redetectMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diploma 5.3 (p. 44-45): a proximity response that works against the ICM displacement of a vertex is dropped.
|
||||||
|
* Next to a contour, proximity pushes the vertices that already crossed to the wrong side and widens the intersection.
|
||||||
|
*/
|
||||||
|
private solveProximityBesideIcm(): void {
|
||||||
|
const {positions, icmVertices, proximitySnapshot, icmDelta} = this;
|
||||||
|
for (let index = 0; index < this.icmVertexCount; index++) {
|
||||||
|
const vertex = icmVertices[index];
|
||||||
|
proximitySnapshot[index * 3] = positions[vertex * 4];
|
||||||
|
proximitySnapshot[index * 3 + 1] = positions[vertex * 4 + 1];
|
||||||
|
proximitySnapshot[index * 3 + 2] = positions[vertex * 4 + 2];
|
||||||
|
}
|
||||||
|
solveProximityConstraints(positions, this.proximityConstraints, this.config.thickness);
|
||||||
|
for (let index = 0; index < this.icmVertexCount; index++) {
|
||||||
|
const vertex = icmVertices[index];
|
||||||
|
const moveX = positions[vertex * 4] - proximitySnapshot[index * 3];
|
||||||
|
const moveY = positions[vertex * 4 + 1] - proximitySnapshot[index * 3 + 1];
|
||||||
|
const moveZ = positions[vertex * 4 + 2] - proximitySnapshot[index * 3 + 2];
|
||||||
|
const alongIcm = moveX * icmDelta[vertex * 3] + moveY * icmDelta[vertex * 3 + 1] + moveZ * icmDelta[vertex * 3 + 2];
|
||||||
|
if (alongIcm < 0) {
|
||||||
|
positions[vertex * 4] = proximitySnapshot[index * 3];
|
||||||
|
positions[vertex * 4 + 1] = proximitySnapshot[index * 3 + 1];
|
||||||
|
positions[vertex * 4 + 2] = proximitySnapshot[index * 3 + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'CLOTH_ICM.TITLE' | translate }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
|
<div class="flex flex-col mb-4">
|
||||||
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
|
<mat-button-toggle-group class="rounded-sm overflow-hidden" [value]="sceneId()" (change)="setScene($event.value)" [aria-label]="'CLOTH_ICM.SCENE' | translate">
|
||||||
|
<mat-button-toggle value="tangled">{{ 'CLOTH_ICM.SCENE_TANGLED' | translate }}</mat-button-toggle>
|
||||||
|
<mat-button-toggle value="drop">{{ 'CLOTH_ICM.SCENE_DROP' | translate }}</mat-button-toggle>
|
||||||
|
</mat-button-toggle-group>
|
||||||
|
<button mat-raised-button color="primary" (click)="toggleIcm()">
|
||||||
|
{{ icmEnabled() ? ('CLOTH_ICM.ICM_OFF' | translate) : ('CLOTH_ICM.ICM_ON' | translate) }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="toggleCulling()">
|
||||||
|
{{ coneCulling() ? ('CLOTH_ICM.CULLING_OFF' | translate) : ('CLOTH_ICM.CULLING_ON' | translate) }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="primary" (click)="toggleBvh()">
|
||||||
|
{{ showBvh() ? ('CLOTH_ICM.BVH_OFF' | translate) : ('CLOTH_ICM.BVH_ON' | translate) }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button color="accent" (click)="restartSimulation()">
|
||||||
|
{{ 'CLOTH_ICM.RESTART_SIMULATION' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<app-slider-field label="CLOTH_ICM.ICM_STEP" [min]="0.05" [max]="0.5" [step]="0.05" [(value)]="icmStep"/>
|
||||||
|
<app-slider-field label="CLOTH_ICM.BVH_DEPTH" [min]="0" [max]="maxBvhDepth" [step]="1" [(value)]="bvhDepth"/>
|
||||||
|
<p class="text-[0.9em] opacity-80">{{ 'CLOTH_ICM.HINT' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
|
<app-babylon-canvas
|
||||||
|
[config]="renderConfig"
|
||||||
|
(sceneReady)="onSceneReady($event)"
|
||||||
|
/>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import {Component, computed, OnDestroy, signal} from '@angular/core';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {MatButtonToggleModule} from '@angular/material/button-toggle';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
import {Observer, Scene} from '@babylonjs/core';
|
||||||
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {SOURCE_PATHS} from '../information/source-paths';
|
||||||
|
import {ClothIcmConfig, ClothIcmSceneId, ClothIcmStats, emptyClothIcmStats, SCENE_SETTINGS} from './cloth-icm.model';
|
||||||
|
import {ClothIcmRenderer} from './cloth-icm-renderer';
|
||||||
|
import {ClothIcmSolver} from './cloth-icm-solver';
|
||||||
|
|
||||||
|
const DEFAULT_ICM_STEP = 0.25;
|
||||||
|
const MAX_BVH_DEPTH = 10;
|
||||||
|
const STATS_EVERY_FRAMES = 10;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-cloth-icm',
|
||||||
|
imports: [
|
||||||
|
MatButton,
|
||||||
|
MatButtonToggleModule,
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
TranslatePipe,
|
||||||
|
BabylonCanvas,
|
||||||
|
SliderFieldComponent,
|
||||||
|
Information,
|
||||||
|
MetricsComponent
|
||||||
|
],
|
||||||
|
templateUrl: './cloth-icm.component.html'
|
||||||
|
})
|
||||||
|
export class ClothIcmComponent implements OnDestroy {
|
||||||
|
private currentSceneData: SceneEventData | null = null;
|
||||||
|
private solver: ClothIcmSolver | null = null;
|
||||||
|
private renderer: ClothIcmRenderer | null = null;
|
||||||
|
private beforeRenderObserver: Observer<Scene> | null = null;
|
||||||
|
private afterRenderObserver: Observer<Scene> | null = null;
|
||||||
|
private frameCount = 0;
|
||||||
|
|
||||||
|
readonly maxBvhDepth = MAX_BVH_DEPTH;
|
||||||
|
readonly sceneId = signal<ClothIcmSceneId>('tangled');
|
||||||
|
readonly icmEnabled = signal(true);
|
||||||
|
readonly coneCulling = signal(true);
|
||||||
|
readonly showBvh = signal(false);
|
||||||
|
readonly bvhDepth = signal(4);
|
||||||
|
readonly icmStep = signal(DEFAULT_ICM_STEP);
|
||||||
|
|
||||||
|
private readonly fpsCounter = new FpsCounter();
|
||||||
|
private readonly stats = signal<ClothIcmStats>(emptyClothIcmStats());
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
|
||||||
|
{label: 'METRICS.PARTICLES', value: SCENE_SETTINGS[this.sceneId()].gridSize ** 2},
|
||||||
|
{label: 'METRICS.INTERSECTIONS', value: this.stats().intersections},
|
||||||
|
{label: 'METRICS.CONTOURS', value: this.stats().contours},
|
||||||
|
{label: 'METRICS.TESTED_PAIRS', value: this.stats().testedPairs},
|
||||||
|
{label: 'METRICS.CULLED_NODES', value: this.stats().culledNodes},
|
||||||
|
{label: 'METRICS.COLLISION_MS', value: this.stats().collisionMs, unit: 'ms', fractionDigits: 1}
|
||||||
|
]);
|
||||||
|
|
||||||
|
readonly renderConfig: RenderConfig = {
|
||||||
|
mode: '3D',
|
||||||
|
initialViewSize: 20
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'CLOTH_ICM.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'CLOTH_ICM.EXPLANATION.ICM_TITLE',
|
||||||
|
description: 'CLOTH_ICM.EXPLANATION.ICM',
|
||||||
|
link: UrlConstants.ICM_PAPER,
|
||||||
|
linkLabel: 'ACM',
|
||||||
|
translateName: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CLOTH_ICM.EXPLANATION.BVH_TITLE',
|
||||||
|
description: 'CLOTH_ICM.EXPLANATION.BVH',
|
||||||
|
link: UrlConstants.KDOP_WIKI,
|
||||||
|
translateName: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CLOTH_ICM.EXPLANATION.PROXIMITY_TITLE',
|
||||||
|
description: 'CLOTH_ICM.EXPLANATION.PROXIMITY',
|
||||||
|
link: UrlConstants.XPBD_WIKI,
|
||||||
|
linkLabel: 'XPBD',
|
||||||
|
translateName: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'CLOTH_ICM.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: '',
|
||||||
|
disclaimerListEntry: ['CLOTH_ICM.EXPLANATION.DISCLAIMER_1', 'CLOTH_ICM.EXPLANATION.DISCLAIMER_2', 'CLOTH_ICM.EXPLANATION.DISCLAIMER_3'],
|
||||||
|
howItWorks: {
|
||||||
|
intro: 'CLOTH_ICM.HOW_IT_WORKS.INTRO',
|
||||||
|
items: [
|
||||||
|
'CLOTH_ICM.HOW_IT_WORKS.ITEM_1',
|
||||||
|
'CLOTH_ICM.HOW_IT_WORKS.ITEM_2',
|
||||||
|
'CLOTH_ICM.HOW_IT_WORKS.ITEM_3',
|
||||||
|
'CLOTH_ICM.HOW_IT_WORKS.ITEM_4',
|
||||||
|
'CLOTH_ICM.HOW_IT_WORKS.ITEM_5'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
sources: SOURCE_PATHS.CLOTH_ICM
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.disposeSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
onSceneReady(event: SceneEventData): void {
|
||||||
|
this.currentSceneData = event;
|
||||||
|
this.createSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
setScene(id: ClothIcmSceneId): void {
|
||||||
|
this.sceneId.set(id);
|
||||||
|
this.createSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleIcm(): void {
|
||||||
|
this.icmEnabled.update(value => !value);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCulling(): void {
|
||||||
|
this.coneCulling.update(value => !value);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleBvh(): void {
|
||||||
|
this.showBvh.update(value => !value);
|
||||||
|
}
|
||||||
|
|
||||||
|
restartSimulation(): void {
|
||||||
|
this.createSimulation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSimulation(): void {
|
||||||
|
if (!this.currentSceneData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const {engine, scene} = this.currentSceneData;
|
||||||
|
this.disposeSimulation();
|
||||||
|
|
||||||
|
this.solver = new ClothIcmSolver(this.clothConfig(), this.sceneId());
|
||||||
|
this.renderer = new ClothIcmRenderer();
|
||||||
|
this.renderer.init(scene, this.solver.topology, this.solver.scene);
|
||||||
|
this.frameCount = 0;
|
||||||
|
|
||||||
|
this.beforeRenderObserver = scene.onBeforeRenderObservable.add(() => {
|
||||||
|
this.stepFrame(engine.getDeltaTime() / 1000);
|
||||||
|
});
|
||||||
|
this.afterRenderObserver = scene.onAfterRenderObservable.add(() => {
|
||||||
|
this.fpsCounter.tick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stepFrame(deltaSeconds: number): void {
|
||||||
|
if (!this.solver || !this.renderer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.solver.bvh.coneCullingEnabled = this.coneCulling();
|
||||||
|
this.solver.step({dt: deltaSeconds, icmEnabled: this.icmEnabled(), icmStep: this.icmStep()});
|
||||||
|
this.renderer.update(this.solver, this.showBvh(), this.bvhDepth());
|
||||||
|
this.frameCount++;
|
||||||
|
if (this.frameCount % STATS_EVERY_FRAMES === 0) {
|
||||||
|
this.stats.set(this.solver.stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private disposeSimulation(): void {
|
||||||
|
const scene = this.currentSceneData?.scene;
|
||||||
|
if (scene && this.beforeRenderObserver) {
|
||||||
|
scene.onBeforeRenderObservable.remove(this.beforeRenderObserver);
|
||||||
|
}
|
||||||
|
if (scene && this.afterRenderObserver) {
|
||||||
|
scene.onAfterRenderObservable.remove(this.afterRenderObserver);
|
||||||
|
}
|
||||||
|
this.beforeRenderObserver = null;
|
||||||
|
this.afterRenderObserver = null;
|
||||||
|
this.renderer?.dispose();
|
||||||
|
this.renderer = null;
|
||||||
|
this.solver = null;
|
||||||
|
this.fpsCounter.reset();
|
||||||
|
this.stats.set(emptyClothIcmStats());
|
||||||
|
}
|
||||||
|
|
||||||
|
private clothConfig(): ClothIcmConfig {
|
||||||
|
const {gridSize, spacing, thickness, proximityRedetectEvery} = SCENE_SETTINGS[this.sceneId()];
|
||||||
|
return {
|
||||||
|
gridWidth: gridSize,
|
||||||
|
gridHeight: gridSize,
|
||||||
|
spacing,
|
||||||
|
particleInvMass: 1 / (spacing * spacing),
|
||||||
|
thickness,
|
||||||
|
proximityRedetectEvery
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export interface ClothIcmConfig {
|
||||||
|
gridWidth: number;
|
||||||
|
gridHeight: number;
|
||||||
|
spacing: number;
|
||||||
|
particleInvMass: number;
|
||||||
|
/** Minimum distance kept by the proximity constraints. */
|
||||||
|
thickness: number;
|
||||||
|
/** Re-detect proximity pairs every this many substeps, 0 = only at frame start. */
|
||||||
|
proximityRedetectEvery: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClothIcmStats {
|
||||||
|
intersections: number;
|
||||||
|
contours: number;
|
||||||
|
testedPairs: number;
|
||||||
|
culledNodes: number;
|
||||||
|
collisionMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClothIcmSceneId = 'tangled' | 'drop';
|
||||||
|
|
||||||
|
export const ICM_SUBSTEPS = 10;
|
||||||
|
export const VELOCITY_DAMPING = 0.98;
|
||||||
|
/** The simulation always steps with this dt: damping and ICM step sizes are per step, so the result must not depend on the refresh rate. */
|
||||||
|
export const SIM_DT = 1 / 60;
|
||||||
|
/** Longest frame time that is simulated; slower frames lose time instead of piling up steps. */
|
||||||
|
export const MAX_DT = 1 / 30;
|
||||||
|
/** Speed limit in m/s: fast cloth tunnels through the thin proximity shell and creates new intersections. */
|
||||||
|
export const MAX_SPEED = 3;
|
||||||
|
/** Stabilizes the ICM displacement of short contours (g0). */
|
||||||
|
export const ICM_G0 = 4;
|
||||||
|
export const DISTANCE_COMPLIANCE = 1e-5;
|
||||||
|
/** Share of the tangential motion removed per frame for vertices that rest on the sphere or the ground. */
|
||||||
|
export const CONTACT_FRICTION = 0.8;
|
||||||
|
|
||||||
|
export function emptyClothIcmStats(): ClothIcmStats {
|
||||||
|
return {intersections: 0, contours: 0, testedPairs: 0, culledNodes: 0, collisionMs: 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneSettings {
|
||||||
|
gridSize: number;
|
||||||
|
spacing: number;
|
||||||
|
thickness: number;
|
||||||
|
proximityRedetectEvery: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop: a 35x35 cloth of the same size (wider spacing), gap 3.5x the tangled gap and one mid-frame re-detection:
|
||||||
|
* ICM on ends with ~0 hits after landing, ICM off stays bounded. A larger gap or a finer grid makes ICM off blow up.
|
||||||
|
*/
|
||||||
|
export const SCENE_SETTINGS: Record<ClothIcmSceneId, SceneSettings> = {
|
||||||
|
tangled: {gridSize: 50, spacing: 0.1, thickness: 0.01, proximityRedetectEvery: 0},
|
||||||
|
drop: {gridSize: 35, spacing: 0.145, thickness: 0.035, proximityRedetectEvery: 5}
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import {initialPositions} from '../cloth-icm-scenes';
|
||||||
|
import {ClothBvh} from './cloth-bvh';
|
||||||
|
import {buildClothTopology} from './cloth-topology';
|
||||||
|
import {DOP_AXES, DOP_SIZE} from './dop18';
|
||||||
|
|
||||||
|
const SPACING = 0.1;
|
||||||
|
|
||||||
|
function flatPositions(gridWidth: number, gridHeight: number): {positions: Float32Array; bvh: ClothBvh} {
|
||||||
|
const topology = buildClothTopology(gridWidth, gridHeight);
|
||||||
|
const positions = new Float32Array(topology.vertexCount * 4);
|
||||||
|
for (let vertex = 0; vertex < topology.vertexCount; vertex++) {
|
||||||
|
positions[vertex * 4] = (vertex % gridWidth) * SPACING;
|
||||||
|
positions[vertex * 4 + 1] = Math.floor(vertex / gridWidth) * SPACING;
|
||||||
|
positions[vertex * 4 + 3] = 1;
|
||||||
|
}
|
||||||
|
const bvh = new ClothBvh(topology);
|
||||||
|
bvh.refit(positions);
|
||||||
|
return {positions, bvh};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tangled(gridSize: number): {positions: Float32Array; bvh: ClothBvh} {
|
||||||
|
const topology = buildClothTopology(gridSize, gridSize);
|
||||||
|
const positions = initialPositions('tangled', topology, SPACING, 1);
|
||||||
|
const bvh = new ClothBvh(topology);
|
||||||
|
bvh.refit(positions);
|
||||||
|
return {positions, bvh};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLeafPairs(bvh: ClothBvh, positions: Float32Array, margin = 0): number {
|
||||||
|
let pairs = 0;
|
||||||
|
bvh.selfPairs(positions, margin, () => {
|
||||||
|
pairs++;
|
||||||
|
});
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ClothBvh', () => {
|
||||||
|
it('builds a tree with one leaf per quad and children after their parent', () => {
|
||||||
|
const {bvh} = flatPositions(5, 4);
|
||||||
|
expect(bvh.nodeCount).toBe(2 * 4 * 3 - 1);
|
||||||
|
for (let node = 0; node < bvh.nodeCount; node++) {
|
||||||
|
if (!bvh.isLeaf(node)) {
|
||||||
|
expect(bvh.left[node]).toBeGreaterThan(node);
|
||||||
|
expect(bvh.right[node]).toBeGreaterThan(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refits bounds that contain every vertex of every leaf', () => {
|
||||||
|
const {positions, bvh} = tangled(8);
|
||||||
|
for (let node = 0; node < bvh.nodeCount; node++) {
|
||||||
|
if (!bvh.isLeaf(node)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const x0 = bvh.quadRange[node * 4];
|
||||||
|
const y0 = bvh.quadRange[node * 4 + 1];
|
||||||
|
const corners = [y0 * 8 + x0, y0 * 8 + x0 + 1, (y0 + 1) * 8 + x0, (y0 + 1) * 8 + x0 + 1];
|
||||||
|
for (const corner of corners) {
|
||||||
|
DOP_AXES.forEach((axis, index) => {
|
||||||
|
const projection = positions[corner * 4] * axis[0] + positions[corner * 4 + 1] * axis[1] + positions[corner * 4 + 2] * axis[2];
|
||||||
|
expect(projection).toBeGreaterThanOrEqual(bvh.bounds[node * DOP_SIZE + index] - 1e-5);
|
||||||
|
expect(projection).toBeLessThanOrEqual(bvh.bounds[node * DOP_SIZE + 9 + index] + 1e-5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refits every inner node so it contains both child bounds', () => {
|
||||||
|
const {bvh} = tangled(8);
|
||||||
|
for (let node = 0; node < bvh.nodeCount; node++) {
|
||||||
|
if (bvh.isLeaf(node)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const child of [bvh.left[node], bvh.right[node]]) {
|
||||||
|
for (let axis = 0; axis < 9; axis++) {
|
||||||
|
expect(bvh.bounds[node * DOP_SIZE + axis]).toBeLessThanOrEqual(bvh.bounds[child * DOP_SIZE + axis]);
|
||||||
|
expect(bvh.bounds[node * DOP_SIZE + 9 + axis]).toBeGreaterThanOrEqual(bvh.bounds[child * DOP_SIZE + 9 + axis]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('culls the root of a flat cloth and reports no leaf pairs', () => {
|
||||||
|
const {positions, bvh} = flatPositions(10, 10);
|
||||||
|
let pairs = 0;
|
||||||
|
const culled = bvh.selfPairs(positions, 0, () => {
|
||||||
|
pairs++;
|
||||||
|
});
|
||||||
|
expect(bvh.culled[0]).toBe(1);
|
||||||
|
expect(culled).toBe(1);
|
||||||
|
expect(pairs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cull the root of a cloth folded through itself and finds leaf pairs', () => {
|
||||||
|
const {positions, bvh} = tangled(30);
|
||||||
|
const pairs = countLeafPairs(bvh, positions);
|
||||||
|
expect(bvh.culled[0]).toBe(0);
|
||||||
|
expect(pairs).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tests fewer leaf pairs with the cone culling than without', () => {
|
||||||
|
const {positions, bvh} = flatPositions(10, 10);
|
||||||
|
const withCulling = countLeafPairs(bvh, positions);
|
||||||
|
bvh.coneCullingEnabled = false;
|
||||||
|
const withoutCulling = countLeafPairs(bvh, positions);
|
||||||
|
expect(bvh.culled.some(flag => flag === 1)).toBe(false);
|
||||||
|
expect(withCulling).toBeLessThan(withoutCulling);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds more pairs with a bigger margin', () => {
|
||||||
|
const {positions, bvh} = flatPositions(10, 10);
|
||||||
|
bvh.coneCullingEnabled = false;
|
||||||
|
expect(countLeafPairs(bvh, positions, SPACING)).toBeGreaterThan(countLeafPairs(bvh, positions, 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import {ClothTopology} from './cloth-topology';
|
||||||
|
import {DOP_SIZE, dopFromTriangle, dopMerge, dopOverlap} from './dop18';
|
||||||
|
import {borderProjectionIsSimple, computeTriangleNormals, coneMergeInto} from './normal-cone';
|
||||||
|
|
||||||
|
/** A region whose normal cone is at least this wide (minus a tolerance) is never a height field. */
|
||||||
|
const MAX_FLAT_CONE_HALF = Math.PI / 2 - 1e-3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounding volume hierarchy over the quads of the cloth grid. The tree shape comes from the grid and never changes,
|
||||||
|
* only the volumes are refit each frame. Children always have larger ids than their parent.
|
||||||
|
*/
|
||||||
|
export class ClothBvh {
|
||||||
|
readonly nodeCount: number;
|
||||||
|
/** -1 on leaves. */
|
||||||
|
readonly left: Int32Array;
|
||||||
|
readonly right: Int32Array;
|
||||||
|
readonly parent: Int32Array;
|
||||||
|
/** x0, y0, x1, y1 per node, in quads, end exclusive. */
|
||||||
|
readonly quadRange: Int32Array;
|
||||||
|
readonly depth: Uint8Array;
|
||||||
|
/** 18 per node. */
|
||||||
|
readonly bounds: Float32Array;
|
||||||
|
readonly coneAxis: Float32Array;
|
||||||
|
readonly coneHalf: Float32Array;
|
||||||
|
/** 3 per triangle, filled in refit. */
|
||||||
|
readonly triangleNormals: Float32Array;
|
||||||
|
/** Per frame: 1 = the self-test of this node was culled. Descendants are never visited. */
|
||||||
|
readonly culled: Uint8Array;
|
||||||
|
/** Switch for the Volino 1994 culling; off = every node is tested. */
|
||||||
|
coneCullingEnabled = true;
|
||||||
|
|
||||||
|
private readonly quadsX: number;
|
||||||
|
private readonly triangleScratch = new Float32Array(DOP_SIZE);
|
||||||
|
private readonly borderScratch: Uint32Array;
|
||||||
|
private readonly axisScratch = new Float32Array(3);
|
||||||
|
private nextNode = 0;
|
||||||
|
|
||||||
|
private positions: Float32Array = new Float32Array(0);
|
||||||
|
private margin = 0;
|
||||||
|
private onLeafPair: (quadA: number, quadB: number) => void = () => undefined;
|
||||||
|
private culledCount = 0;
|
||||||
|
|
||||||
|
constructor(private readonly topology: ClothTopology) {
|
||||||
|
const quadsX = topology.gridWidth - 1;
|
||||||
|
const quadsY = topology.gridHeight - 1;
|
||||||
|
this.quadsX = quadsX;
|
||||||
|
this.nodeCount = 2 * quadsX * quadsY - 1;
|
||||||
|
this.left = new Int32Array(this.nodeCount).fill(-1);
|
||||||
|
this.right = new Int32Array(this.nodeCount).fill(-1);
|
||||||
|
this.parent = new Int32Array(this.nodeCount).fill(-1);
|
||||||
|
this.quadRange = new Int32Array(this.nodeCount * 4);
|
||||||
|
this.depth = new Uint8Array(this.nodeCount);
|
||||||
|
this.bounds = new Float32Array(this.nodeCount * DOP_SIZE);
|
||||||
|
this.coneAxis = new Float32Array(this.nodeCount * 3);
|
||||||
|
this.coneHalf = new Float32Array(this.nodeCount);
|
||||||
|
this.triangleNormals = new Float32Array(topology.triangles.length);
|
||||||
|
this.culled = new Uint8Array(this.nodeCount);
|
||||||
|
this.borderScratch = new Uint32Array(2 * (quadsX + quadsY));
|
||||||
|
this.build(0, 0, quadsX, quadsY, 0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLeaf(node: number): boolean {
|
||||||
|
return this.left[node] < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bottom-up: children have larger ids than parents, so one reverse pass is enough. */
|
||||||
|
refit(positions: Float32Array): void {
|
||||||
|
computeTriangleNormals(positions, this.topology.triangles, this.triangleNormals);
|
||||||
|
for (let node = this.nodeCount - 1; node >= 0; node--) {
|
||||||
|
if (this.isLeaf(node)) {
|
||||||
|
this.refitLeaf(positions, node);
|
||||||
|
} else {
|
||||||
|
this.refitInner(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-traversal. Calls `onLeafPair` once for every pair of different leaves whose volumes overlap within `margin`.
|
||||||
|
* Returns the number of nodes whose self-test was culled.
|
||||||
|
*/
|
||||||
|
selfPairs(positions: Float32Array, margin: number, onLeafPair: (quadA: number, quadB: number) => void): number {
|
||||||
|
this.positions = positions;
|
||||||
|
this.margin = margin;
|
||||||
|
this.onLeafPair = onLeafPair;
|
||||||
|
this.culledCount = 0;
|
||||||
|
this.culled.fill(0);
|
||||||
|
this.selfNode(0);
|
||||||
|
return this.culledCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private build(x0: number, y0: number, x1: number, y1: number, depth: number, parent: number): void {
|
||||||
|
const node = this.nextNode++;
|
||||||
|
this.parent[node] = parent;
|
||||||
|
this.depth[node] = depth;
|
||||||
|
this.quadRange.set([x0, y0, x1, y1], node * 4);
|
||||||
|
const width = x1 - x0;
|
||||||
|
const height = y1 - y0;
|
||||||
|
if (width === 1 && height === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.left[node] = this.nextNode;
|
||||||
|
if (width >= height) {
|
||||||
|
const middle = x0 + Math.floor(width / 2);
|
||||||
|
this.build(x0, y0, middle, y1, depth + 1, node);
|
||||||
|
this.right[node] = this.nextNode;
|
||||||
|
this.build(middle, y0, x1, y1, depth + 1, node);
|
||||||
|
} else {
|
||||||
|
const middle = y0 + Math.floor(height / 2);
|
||||||
|
this.build(x0, y0, x1, middle, depth + 1, node);
|
||||||
|
this.right[node] = this.nextNode;
|
||||||
|
this.build(x0, middle, x1, y1, depth + 1, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private leafQuad(node: number): number {
|
||||||
|
return this.quadRange[node * 4 + 1] * this.quadsX + this.quadRange[node * 4];
|
||||||
|
}
|
||||||
|
|
||||||
|
private refitLeaf(positions: Float32Array, node: number): void {
|
||||||
|
const quad = this.leafQuad(node);
|
||||||
|
const offset = node * DOP_SIZE;
|
||||||
|
dopFromTriangle(positions, this.topology, quad * 2, this.bounds, offset);
|
||||||
|
dopFromTriangle(positions, this.topology, quad * 2 + 1, this.triangleScratch, 0);
|
||||||
|
dopMerge(this.bounds, offset, this.triangleScratch, 0, this.bounds, offset);
|
||||||
|
this.coneHalf[node] = coneMergeInto(this.triangleNormals, quad * 6, 0, this.triangleNormals, quad * 6 + 3, 0, this.coneAxis, node * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private refitInner(node: number): void {
|
||||||
|
const left = this.left[node];
|
||||||
|
const right = this.right[node];
|
||||||
|
dopMerge(this.bounds, left * DOP_SIZE, this.bounds, right * DOP_SIZE, this.bounds, node * DOP_SIZE);
|
||||||
|
this.coneHalf[node] = coneMergeInto(
|
||||||
|
this.coneAxis, left * 3, this.coneHalf[left],
|
||||||
|
this.coneAxis, right * 3, this.coneHalf[right],
|
||||||
|
this.coneAxis, node * 3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Culling is per node, never per triangle: the region must be a height field over its own border. */
|
||||||
|
private selfTestIsCulled(node: number): boolean {
|
||||||
|
if (!this.coneCullingEnabled || this.coneHalf[node] >= MAX_FLAT_CONE_HALF) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const borderLength = this.fillBorderVertices(node);
|
||||||
|
this.axisScratch[0] = this.coneAxis[node * 3];
|
||||||
|
this.axisScratch[1] = this.coneAxis[node * 3 + 1];
|
||||||
|
this.axisScratch[2] = this.coneAxis[node * 3 + 2];
|
||||||
|
return borderProjectionIsSimple(this.positions, this.borderScratch, this.axisScratch, borderLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the closed border loop of the node into `borderScratch` and returns its length. */
|
||||||
|
private fillBorderVertices(node: number): number {
|
||||||
|
const x0 = this.quadRange[node * 4];
|
||||||
|
const y0 = this.quadRange[node * 4 + 1];
|
||||||
|
const x1 = this.quadRange[node * 4 + 2];
|
||||||
|
const y1 = this.quadRange[node * 4 + 3];
|
||||||
|
const gridWidth = this.topology.gridWidth;
|
||||||
|
const border = this.borderScratch;
|
||||||
|
let length = 0;
|
||||||
|
for (let x = x0; x < x1; x++) {
|
||||||
|
border[length++] = y0 * gridWidth + x;
|
||||||
|
}
|
||||||
|
for (let y = y0; y < y1; y++) {
|
||||||
|
border[length++] = y * gridWidth + x1;
|
||||||
|
}
|
||||||
|
for (let x = x1; x > x0; x--) {
|
||||||
|
border[length++] = y1 * gridWidth + x;
|
||||||
|
}
|
||||||
|
for (let y = y1; y > y0; y--) {
|
||||||
|
border[length++] = y * gridWidth + x0;
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selfNode(node: number): void {
|
||||||
|
if (this.isLeaf(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.selfTestIsCulled(node)) {
|
||||||
|
this.culled[node] = 1;
|
||||||
|
this.culledCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const left = this.left[node];
|
||||||
|
const right = this.right[node];
|
||||||
|
this.selfNode(left);
|
||||||
|
this.selfNode(right);
|
||||||
|
this.pairNodes(left, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pairNodes(a: number, b: number): void {
|
||||||
|
if (!dopOverlap(this.bounds, a * DOP_SIZE, this.bounds, b * DOP_SIZE, this.margin)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const aIsLeaf = this.isLeaf(a);
|
||||||
|
const bIsLeaf = this.isLeaf(b);
|
||||||
|
if (aIsLeaf && bIsLeaf) {
|
||||||
|
this.onLeafPair(this.leafQuad(a), this.leafQuad(b));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const splitA = !aIsLeaf && (bIsLeaf || this.quadArea(a) >= this.quadArea(b));
|
||||||
|
if (splitA) {
|
||||||
|
this.pairNodes(this.left[a], b);
|
||||||
|
this.pairNodes(this.right[a], b);
|
||||||
|
} else {
|
||||||
|
this.pairNodes(a, this.left[b]);
|
||||||
|
this.pairNodes(a, this.right[b]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private quadArea(node: number): number {
|
||||||
|
const base = node * 4;
|
||||||
|
return (this.quadRange[base + 2] - this.quadRange[base]) * (this.quadRange[base + 3] - this.quadRange[base + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {buildClothTopology, buildDistanceConstraints, trianglesShareVertex} from './cloth-topology';
|
||||||
|
|
||||||
|
describe('cloth topology', () => {
|
||||||
|
const topology = buildClothTopology(4, 3);
|
||||||
|
|
||||||
|
it('has the expected counts', () => {
|
||||||
|
const quads = 3 * 2;
|
||||||
|
expect(topology.vertexCount).toBe(12);
|
||||||
|
expect(topology.triangles.length / 3).toBe(quads * 2);
|
||||||
|
// horizontal 3*3 + vertical 4*2 + diagonal 6
|
||||||
|
expect(topology.edges.length / 2).toBe(9 + 8 + 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives border edges one triangle and inner edges two', () => {
|
||||||
|
const edgeCount = topology.edges.length / 2;
|
||||||
|
let borderEdges = 0;
|
||||||
|
for (let edge = 0; edge < edgeCount; edge++) {
|
||||||
|
expect(topology.edgeTriangles[edge * 2]).toBeGreaterThanOrEqual(0);
|
||||||
|
if (topology.edgeTriangles[edge * 2 + 1] === -1) {
|
||||||
|
borderEdges++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(borderEdges).toBe(2 * (3 + 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets both triangles of a quad share the diagonal edge', () => {
|
||||||
|
const first = Array.from(topology.triangleEdges.slice(0, 3));
|
||||||
|
const second = Array.from(topology.triangleEdges.slice(3, 6));
|
||||||
|
const shared = first.filter(edge => second.includes(edge));
|
||||||
|
expect(shared.length).toBe(1);
|
||||||
|
const edge = shared[0];
|
||||||
|
expect(topology.edgeTriangles[edge * 2]).toBe(0);
|
||||||
|
expect(topology.edgeTriangles[edge * 2 + 1]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects triangles that share a vertex', () => {
|
||||||
|
expect(trianglesShareVertex(topology, 0, 1)).toBe(true);
|
||||||
|
expect(trianglesShareVertex(topology, 0, 2)).toBe(true);
|
||||||
|
expect(trianglesShareVertex(topology, 0, 11)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes distance constraints as [a, b, rest, 1] with the second diagonal added', () => {
|
||||||
|
const constraints = buildDistanceConstraints(topology, 0.5);
|
||||||
|
expect(constraints.length / 4).toBe(23 + 6);
|
||||||
|
expect(Array.from(constraints.slice(0, 4))).toEqual([topology.edges[0], topology.edges[1], 0.5, 1]);
|
||||||
|
const diagonalRest = 0.5 * Math.SQRT2;
|
||||||
|
const last = constraints.length - 4;
|
||||||
|
expect(constraints[last + 2]).toBeCloseTo(diagonalRest);
|
||||||
|
expect(constraints[last + 3]).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
export interface ClothTopology {
|
||||||
|
gridWidth: number;
|
||||||
|
gridHeight: number;
|
||||||
|
vertexCount: number;
|
||||||
|
/** 3 ids per triangle, same winding for all. */
|
||||||
|
triangles: Uint32Array;
|
||||||
|
/** 2 ids per edge (horizontal, vertical, one diagonal). */
|
||||||
|
edges: Uint32Array;
|
||||||
|
/** 2 per edge, -1 = border. */
|
||||||
|
edgeTriangles: Int32Array;
|
||||||
|
/** 3 per triangle. */
|
||||||
|
triangleEdges: Uint32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vertex id = y * gridWidth + x. Quad q = y * (gridWidth - 1) + x has triangles 2q and 2q + 1. */
|
||||||
|
export function buildClothTopology(gridWidth: number, gridHeight: number): ClothTopology {
|
||||||
|
const vertexCount = gridWidth * gridHeight;
|
||||||
|
const triangles = buildTriangles(gridWidth, gridHeight);
|
||||||
|
const triangleCount = triangles.length / 3;
|
||||||
|
const edgeIndexByKey = new Map<number, number>();
|
||||||
|
const edgeList: number[] = [];
|
||||||
|
const edgeTriangleList: number[] = [];
|
||||||
|
const triangleEdges = new Uint32Array(triangleCount * 3);
|
||||||
|
|
||||||
|
for (let triangle = 0; triangle < triangleCount; triangle++) {
|
||||||
|
for (let corner = 0; corner < 3; corner++) {
|
||||||
|
const a = triangles[triangle * 3 + corner];
|
||||||
|
const b = triangles[triangle * 3 + (corner + 1) % 3];
|
||||||
|
const key = Math.min(a, b) * vertexCount + Math.max(a, b);
|
||||||
|
let edge = edgeIndexByKey.get(key);
|
||||||
|
if (edge === undefined) {
|
||||||
|
edge = edgeList.length / 2;
|
||||||
|
edgeIndexByKey.set(key, edge);
|
||||||
|
edgeList.push(a, b);
|
||||||
|
edgeTriangleList.push(triangle, -1);
|
||||||
|
} else {
|
||||||
|
edgeTriangleList[edge * 2 + 1] = triangle;
|
||||||
|
}
|
||||||
|
triangleEdges[triangle * 3 + corner] = edge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
vertexCount,
|
||||||
|
triangles,
|
||||||
|
edges: Uint32Array.from(edgeList),
|
||||||
|
edgeTriangles: Int32Array.from(edgeTriangleList),
|
||||||
|
triangleEdges
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTriangles(gridWidth: number, gridHeight: number): Uint32Array {
|
||||||
|
const quadsX = gridWidth - 1;
|
||||||
|
const quadsY = gridHeight - 1;
|
||||||
|
const triangles = new Uint32Array(quadsX * quadsY * 6);
|
||||||
|
for (let y = 0; y < quadsY; y++) {
|
||||||
|
for (let x = 0; x < quadsX; x++) {
|
||||||
|
const quad = y * quadsX + x;
|
||||||
|
const v00 = y * gridWidth + x;
|
||||||
|
const v10 = v00 + 1;
|
||||||
|
const v01 = v00 + gridWidth;
|
||||||
|
const v11 = v01 + 1;
|
||||||
|
triangles.set([v00, v01, v11, v00, v11, v10], quad * 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return triangles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trianglesShareVertex(topology: ClothTopology, a: number, b: number): boolean {
|
||||||
|
const {triangles} = topology;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const vertex = triangles[a * 3 + i];
|
||||||
|
for (let j = 0; j < 3; j++) {
|
||||||
|
if (vertex === triangles[b * 3 + j]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Layout of `solveConstraints`: [a, b, restLength, type] per edge, plus the second diagonal of every quad. */
|
||||||
|
export function buildDistanceConstraints(topology: ClothTopology, spacing: number): Float32Array {
|
||||||
|
const {gridWidth, gridHeight, edges} = topology;
|
||||||
|
const constraints: number[] = [];
|
||||||
|
const addConstraint = (a: number, b: number): void => {
|
||||||
|
const dx = (a % gridWidth) - (b % gridWidth);
|
||||||
|
const dy = Math.floor(a / gridWidth) - Math.floor(b / gridWidth);
|
||||||
|
constraints.push(a, b, spacing * Math.hypot(dx, dy), 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let edge = 0; edge < edges.length / 2; edge++) {
|
||||||
|
addConstraint(edges[edge * 2], edges[edge * 2 + 1]);
|
||||||
|
}
|
||||||
|
for (let y = 0; y < gridHeight - 1; y++) {
|
||||||
|
for (let x = 0; x < gridWidth - 1; x++) {
|
||||||
|
addConstraint(y * gridWidth + x + 1, (y + 1) * gridWidth + x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Float32Array.from(constraints);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {ClothBvh} from './cloth-bvh';
|
||||||
|
import {ClothTopology, trianglesShareVertex} from './cloth-topology';
|
||||||
|
import {DOP_SIZE, dopFromTriangle, dopOverlap} from './dop18';
|
||||||
|
import {IntersectionSet} from './intersection-set';
|
||||||
|
|
||||||
|
export interface DetectionCounters {
|
||||||
|
/** Triangle pairs that went to the edge-triangle test. */
|
||||||
|
testedPairs: number;
|
||||||
|
culledNodes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triangleDops = new Float32Array(DOP_SIZE * 2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broad phase (BVH self-traversal with normal-cone culling) and narrow phase (edge-triangle).
|
||||||
|
* Fills `set` with the hits and `proximityCandidates` with triangle pairs (2 ids each) that do not
|
||||||
|
* intersect but are closer than `margin`. Expects a refit BVH.
|
||||||
|
*/
|
||||||
|
export function detectCollisions(
|
||||||
|
bvh: ClothBvh, positions: Float32Array, topology: ClothTopology, margin: number,
|
||||||
|
set: IntersectionSet, proximityCandidates: number[]
|
||||||
|
): DetectionCounters {
|
||||||
|
set.clear();
|
||||||
|
proximityCandidates.length = 0;
|
||||||
|
let testedPairs = 0;
|
||||||
|
|
||||||
|
const testTrianglePair = (triA: number, triB: number): void => {
|
||||||
|
if (trianglesShareVertex(topology, triA, triB)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
testedPairs++;
|
||||||
|
const hits = set.addTrianglePair(positions, topology, triA, triB);
|
||||||
|
if (hits > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dopFromTriangle(positions, topology, triA, triangleDops, 0);
|
||||||
|
dopFromTriangle(positions, topology, triB, triangleDops, DOP_SIZE);
|
||||||
|
if (dopOverlap(triangleDops, 0, triangleDops, DOP_SIZE, margin)) {
|
||||||
|
proximityCandidates.push(triA, triB);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const culledNodes = bvh.selfPairs(positions, margin, (quadA, quadB) => {
|
||||||
|
testTrianglePair(quadA * 2, quadB * 2);
|
||||||
|
testTrianglePair(quadA * 2, quadB * 2 + 1);
|
||||||
|
testTrianglePair(quadA * 2 + 1, quadB * 2);
|
||||||
|
testTrianglePair(quadA * 2 + 1, quadB * 2 + 1);
|
||||||
|
});
|
||||||
|
return {testedPairs, culledNodes};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {buildClothTopology} from './cloth-topology';
|
||||||
|
import {DOP_SIZE, dopFromTriangle, dopMerge, dopOverlap} from './dop18';
|
||||||
|
|
||||||
|
/** Vertex layout: [x, y, z, invMass]. */
|
||||||
|
function vertices(...rows: number[][]): Float32Array {
|
||||||
|
return new Float32Array(rows.flat());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('18-DOP', () => {
|
||||||
|
const topology = buildClothTopology(2, 2);
|
||||||
|
const positions = vertices([0, 0, 0, 1], [1, 0, 0, 1], [0, 1, 0, 1], [1, 1, 0, 1]);
|
||||||
|
|
||||||
|
function triangleDop(offset: number, triangle: number, shift: number): Float32Array {
|
||||||
|
const moved = positions.slice();
|
||||||
|
for (let vertex = 0; vertex < 4; vertex++) {
|
||||||
|
moved[vertex * 4 + 2] += shift;
|
||||||
|
}
|
||||||
|
const out = new Float32Array(DOP_SIZE + offset);
|
||||||
|
dopFromTriangle(moved, topology, triangle, out, offset);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('bounds the three vertices on every axis', () => {
|
||||||
|
const dop = triangleDop(0, 0, 0);
|
||||||
|
// triangle 0 = vertices 0, 2, 3: (0,0,0), (0,1,0), (1,1,0); axis 3 = x + y
|
||||||
|
expect(dop[0]).toBe(0);
|
||||||
|
expect(dop[9]).toBe(1);
|
||||||
|
expect(dop[3]).toBe(0);
|
||||||
|
expect(dop[12]).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overlaps with a margin but not without', () => {
|
||||||
|
const a = triangleDop(0, 0, 0);
|
||||||
|
expect(dopOverlap(a, 0, triangleDop(0, 0, 0), 0, 0)).toBe(true);
|
||||||
|
const apart = triangleDop(0, 0, 0.5);
|
||||||
|
expect(dopOverlap(a, 0, apart, 0, 0)).toBe(false);
|
||||||
|
expect(dopOverlap(a, 0, apart, 0, 0.5)).toBe(true);
|
||||||
|
expect(dopOverlap(a, 0, triangleDop(0, 0, 2), 0, 0.5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges to a volume that contains both parts, in place and with offsets', () => {
|
||||||
|
const a = triangleDop(0, 0, 0);
|
||||||
|
const b = triangleDop(DOP_SIZE, 1, 3);
|
||||||
|
const merged = new Float32Array(DOP_SIZE);
|
||||||
|
dopMerge(a, 0, b, DOP_SIZE, merged, 0);
|
||||||
|
expect(merged[2]).toBe(0);
|
||||||
|
expect(merged[11]).toBe(3);
|
||||||
|
expect(dopOverlap(merged, 0, a, 0, 0)).toBe(true);
|
||||||
|
expect(dopOverlap(merged, 0, b, DOP_SIZE, 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overlapsWithinMarginAlongADiagonalAxis', () => {
|
||||||
|
const flat = vertices([0, 0, 0, 1], [1, 0, 0, 1], [0, 1, 0, 1], [1, 1, 0, 1]);
|
||||||
|
const moved = flat.slice();
|
||||||
|
for (let vertex = 0; vertex < 4; vertex++) {
|
||||||
|
moved[vertex * 4] += 1.45;
|
||||||
|
moved[vertex * 4 + 1] += 1.45;
|
||||||
|
}
|
||||||
|
const a = new Float32Array(DOP_SIZE);
|
||||||
|
const b = new Float32Array(DOP_SIZE);
|
||||||
|
// Triangle 0 = (0,0,0), (0,1,0), (1,1,0). The moved copy starts at (1.45,1.45,0): closest gap is 0.45 * sqrt 2 = 0.64.
|
||||||
|
dopFromTriangle(flat, topology, 0, a, 0);
|
||||||
|
dopFromTriangle(moved, topology, 0, b, 0);
|
||||||
|
const distance = Math.hypot(0.45, 0.45);
|
||||||
|
const margin = 0.7;
|
||||||
|
expect(distance).toBeLessThan(margin);
|
||||||
|
expect(dopOverlap(a, 0, b, 0, margin)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {ClothTopology} from './cloth-topology';
|
||||||
|
|
||||||
|
export const DOP_AXIS_COUNT = 9;
|
||||||
|
export const DOP_SIZE = DOP_AXIS_COUNT * 2;
|
||||||
|
|
||||||
|
/** Not normalized: the slab bounds are plain dot products with these vectors. */
|
||||||
|
export const DOP_AXES: readonly (readonly [number, number, number])[] = [
|
||||||
|
[1, 0, 0], [0, 1, 0], [0, 0, 1],
|
||||||
|
[1, 1, 0], [1, -1, 0],
|
||||||
|
[1, 0, 1], [1, 0, -1],
|
||||||
|
[0, 1, 1], [0, 1, -1]
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Layout per node: [min0..min8, max0..max8]. Positions use the vec4 layout [x, y, z, invMass]. */
|
||||||
|
export function dopFromTriangle(positions: Float32Array, topology: ClothTopology, triangle: number, out: Float32Array, offset: number): void {
|
||||||
|
for (let axis = 0; axis < DOP_AXIS_COUNT; axis++) {
|
||||||
|
out[offset + axis] = Infinity;
|
||||||
|
out[offset + DOP_AXIS_COUNT + axis] = -Infinity;
|
||||||
|
}
|
||||||
|
for (let corner = 0; corner < 3; corner++) {
|
||||||
|
const base = topology.triangles[triangle * 3 + corner] * 4;
|
||||||
|
const x = positions[base];
|
||||||
|
const y = positions[base + 1];
|
||||||
|
const z = positions[base + 2];
|
||||||
|
for (let axis = 0; axis < DOP_AXIS_COUNT; axis++) {
|
||||||
|
const direction = DOP_AXES[axis];
|
||||||
|
const projection = x * direction[0] + y * direction[1] + z * direction[2];
|
||||||
|
if (projection < out[offset + axis]) {
|
||||||
|
out[offset + axis] = projection;
|
||||||
|
}
|
||||||
|
if (projection > out[offset + DOP_AXIS_COUNT + axis]) {
|
||||||
|
out[offset + DOP_AXIS_COUNT + axis] = projection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `out` may be the same array and offset as `a` or `b`. */
|
||||||
|
export function dopMerge(a: Float32Array, offsetA: number, b: Float32Array, offsetB: number, out: Float32Array, offsetOut: number): void {
|
||||||
|
for (let axis = 0; axis < DOP_AXIS_COUNT; axis++) {
|
||||||
|
out[offsetOut + axis] = Math.min(a[offsetA + axis], b[offsetB + axis]);
|
||||||
|
const max = DOP_AXIS_COUNT + axis;
|
||||||
|
out[offsetOut + max] = Math.max(a[offsetA + max], b[offsetB + max]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AXIS_LENGTHS: readonly number[] = DOP_AXES.map(([x, y, z]) => Math.hypot(x, y, z));
|
||||||
|
|
||||||
|
/** True when the slabs overlap on every axis; `margin` is a distance, scaled by the length of each (unnormalized) axis. */
|
||||||
|
export function dopOverlap(a: Float32Array, offsetA: number, b: Float32Array, offsetB: number, margin: number): boolean {
|
||||||
|
for (let axis = 0; axis < DOP_AXIS_COUNT; axis++) {
|
||||||
|
const max = DOP_AXIS_COUNT + axis;
|
||||||
|
const axisMargin = margin * AXIS_LENGTHS[axis];
|
||||||
|
if (a[offsetA + axis] > b[offsetB + max] + axisMargin || b[offsetB + axis] > a[offsetA + max] + axisMargin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {EdgeTriangleHit, intersectEdgeTriangle, newEdgeTriangleHit} from './edge-triangle';
|
||||||
|
|
||||||
|
/** Vertex layout: [x, y, z, invMass]. Triangle = vertices 0..2, edge = vertices 3 and 4. */
|
||||||
|
function scene(edgeStart: number[], edgeEnd: number[]): Float32Array {
|
||||||
|
return new Float32Array([
|
||||||
|
0, 0, 0, 1,
|
||||||
|
4, 0, 0, 1,
|
||||||
|
0, 0, 4, 1,
|
||||||
|
...edgeStart, 1,
|
||||||
|
...edgeEnd, 1
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hit(edgeStart: number[], edgeEnd: number[]): {hit: boolean; out: EdgeTriangleHit} {
|
||||||
|
const out = newEdgeTriangleHit();
|
||||||
|
return {hit: intersectEdgeTriangle(scene(edgeStart, edgeEnd), 3, 4, 0, 1, 2, out), out};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('intersectEdgeTriangle', () => {
|
||||||
|
it('hits and reports the point, the edge parameter and the barycentrics', () => {
|
||||||
|
const result = hit([1, -1, 1], [1, 3, 1]);
|
||||||
|
expect(result.hit).toBe(true);
|
||||||
|
expect(result.out.t).toBeCloseTo(0.25);
|
||||||
|
expect([result.out.x, result.out.y, result.out.z]).toEqual([1, 0, 1]);
|
||||||
|
expect(result.out.u).toBeCloseTo(0.25);
|
||||||
|
expect(result.out.v).toBeCloseTo(0.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('misses beside the triangle', () => {
|
||||||
|
expect(hit([3, -1, 3], [3, 1, 3]).hit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('misses when the segment ends before the plane', () => {
|
||||||
|
expect(hit([1, 1, 1], [1, 3, 1]).hit).toBe(false);
|
||||||
|
expect(hit([1, -3, 1], [1, -1, 1]).hit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives false for an edge parallel to the plane or inside it', () => {
|
||||||
|
expect(hit([1, 1, 1], [2, 1, 1]).hit).toBe(false);
|
||||||
|
expect(hit([1, 0, 1], [2, 0, 1]).hit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a hit exactly on a vertex', () => {
|
||||||
|
const result = hit([0, -1, 0], [0, 1, 0]);
|
||||||
|
expect(result.hit).toBe(true);
|
||||||
|
expect(result.out.t).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
export interface EdgeTriangleHit {
|
||||||
|
/** Position of the hit along the edge, 0 at edgeA and 1 at edgeB. */
|
||||||
|
t: number;
|
||||||
|
u: number;
|
||||||
|
v: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newEdgeTriangleHit(): EdgeTriangleHit {
|
||||||
|
return {t: 0, u: 0, v: 0, x: 0, y: 0, z: 0};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
const MIN_DETERMINANT = 1e-12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moller-Trumbore with a segment range and inclusive borders (EPS).
|
||||||
|
* An edge parallel to the triangle plane or inside it gives false.
|
||||||
|
* Positions use the vec4 layout [x, y, z, invMass]; the other arguments are vertex ids.
|
||||||
|
*/
|
||||||
|
export function intersectEdgeTriangle(
|
||||||
|
positions: Float32Array, edgeA: number, edgeB: number, triA: number, triB: number, triC: number, out: EdgeTriangleHit
|
||||||
|
): boolean {
|
||||||
|
const pa = edgeA * 4;
|
||||||
|
const pb = edgeB * 4;
|
||||||
|
const va = triA * 4;
|
||||||
|
const vb = triB * 4;
|
||||||
|
const vc = triC * 4;
|
||||||
|
|
||||||
|
const dx = positions[pb] - positions[pa];
|
||||||
|
const dy = positions[pb + 1] - positions[pa + 1];
|
||||||
|
const dz = positions[pb + 2] - positions[pa + 2];
|
||||||
|
const e1x = positions[vb] - positions[va];
|
||||||
|
const e1y = positions[vb + 1] - positions[va + 1];
|
||||||
|
const e1z = positions[vb + 2] - positions[va + 2];
|
||||||
|
const e2x = positions[vc] - positions[va];
|
||||||
|
const e2y = positions[vc + 1] - positions[va + 1];
|
||||||
|
const e2z = positions[vc + 2] - positions[va + 2];
|
||||||
|
|
||||||
|
const px = dy * e2z - dz * e2y;
|
||||||
|
const py = dz * e2x - dx * e2z;
|
||||||
|
const pz = dx * e2y - dy * e2x;
|
||||||
|
const determinant = e1x * px + e1y * py + e1z * pz;
|
||||||
|
if (Math.abs(determinant) < MIN_DETERMINANT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const inverse = 1 / determinant;
|
||||||
|
|
||||||
|
const tx = positions[pa] - positions[va];
|
||||||
|
const ty = positions[pa + 1] - positions[va + 1];
|
||||||
|
const tz = positions[pa + 2] - positions[va + 2];
|
||||||
|
const u = (tx * px + ty * py + tz * pz) * inverse;
|
||||||
|
if (u < -EPS || u > 1 + EPS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qx = ty * e1z - tz * e1y;
|
||||||
|
const qy = tz * e1x - tx * e1z;
|
||||||
|
const qz = tx * e1y - ty * e1x;
|
||||||
|
const v = (dx * qx + dy * qy + dz * qz) * inverse;
|
||||||
|
if (v < -EPS || u + v > 1 + EPS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = (e2x * qx + e2y * qy + e2z * qz) * inverse;
|
||||||
|
if (t < -EPS || t > 1 + EPS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.t = t;
|
||||||
|
out.u = u;
|
||||||
|
out.v = v;
|
||||||
|
out.x = positions[pa] + t * dx;
|
||||||
|
out.y = positions[pa + 1] + t * dy;
|
||||||
|
out.z = positions[pa + 2] + t * dz;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {traceContours} from './intersection-contours';
|
||||||
|
import {IntersectionSet} from './intersection-set';
|
||||||
|
|
||||||
|
function setOf(hitCount: number, segments: number[][]): IntersectionSet {
|
||||||
|
const set = new IntersectionSet();
|
||||||
|
set.count = hitCount;
|
||||||
|
set.segments = segments.flat();
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('traceContours', () => {
|
||||||
|
it('walks one closed loop', () => {
|
||||||
|
const contours = traceContours(setOf(4, [[0, 1], [1, 2], [2, 3], [3, 0]]));
|
||||||
|
expect(contours.length).toBe(1);
|
||||||
|
expect(contours[0].closed).toBe(true);
|
||||||
|
expect([...contours[0].points].sort()).toEqual([0, 1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks an open path from one border end to the other', () => {
|
||||||
|
const contours = traceContours(setOf(3, [[1, 0], [1, 2]]));
|
||||||
|
expect(contours.length).toBe(1);
|
||||||
|
expect(contours[0].closed).toBe(false);
|
||||||
|
const {points} = contours[0];
|
||||||
|
expect(points.length).toBe(3);
|
||||||
|
expect([points[0], points[2]].sort()).toEqual([0, 2]);
|
||||||
|
expect(points[1]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds two separate loops', () => {
|
||||||
|
const contours = traceContours(setOf(6, [[0, 1], [1, 2], [2, 0], [3, 4], [4, 5], [5, 3]]));
|
||||||
|
expect(contours.length).toBe(2);
|
||||||
|
expect(contours.every(contour => contour.closed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds a loop next to an open path', () => {
|
||||||
|
const contours = traceContours(setOf(6, [[0, 1], [1, 2], [3, 4], [4, 5], [5, 3]]));
|
||||||
|
expect(contours.map(contour => contour.closed).sort()).toEqual([false, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('terminates on a hit with three segments', () => {
|
||||||
|
const contours = traceContours(setOf(4, [[0, 1], [0, 2], [0, 3]]));
|
||||||
|
const walked = contours.flatMap(contour => contour.points);
|
||||||
|
expect(new Set(walked).size).toBe(walked.length);
|
||||||
|
expect(walked.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for an empty set', () => {
|
||||||
|
expect(traceContours(setOf(0, []))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {IntersectionSet} from './intersection-set';
|
||||||
|
|
||||||
|
export interface Contour {
|
||||||
|
/** Hit indices in walking order. Consecutive points are joined by a segment. */
|
||||||
|
points: number[];
|
||||||
|
/** Closed loops walk back to their first point; the first point is not repeated. */
|
||||||
|
closed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins the segments of a set into contours. Hits with one segment (cloth border) start open paths,
|
||||||
|
* what is left are closed loops. A hit with three or more segments is walked through once and never loops forever.
|
||||||
|
*/
|
||||||
|
export function traceContours(set: IntersectionSet): Contour[] {
|
||||||
|
const neighbours = buildNeighbours(set);
|
||||||
|
const visited = new Uint8Array(set.count);
|
||||||
|
const contours: Contour[] = [];
|
||||||
|
|
||||||
|
for (let hit = 0; hit < set.count; hit++) {
|
||||||
|
if (neighbours[hit].length === 1 && !visited[hit]) {
|
||||||
|
contours.push(walk(hit, neighbours, visited, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let hit = 0; hit < set.count; hit++) {
|
||||||
|
if (neighbours[hit].length > 0 && !visited[hit]) {
|
||||||
|
contours.push(walk(hit, neighbours, visited, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contours.filter(contour => contour.points.length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNeighbours(set: IntersectionSet): number[][] {
|
||||||
|
const neighbours: number[][] = Array.from({length: set.count}, () => []);
|
||||||
|
for (let segment = 0; segment < set.segmentCount; segment++) {
|
||||||
|
const a = set.segments[segment * 2];
|
||||||
|
const b = set.segments[segment * 2 + 1];
|
||||||
|
neighbours[a].push(b);
|
||||||
|
neighbours[b].push(a);
|
||||||
|
}
|
||||||
|
return neighbours;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(start: number, neighbours: number[][], visited: Uint8Array, closedWalk: boolean): Contour {
|
||||||
|
const points = [start];
|
||||||
|
visited[start] = 1;
|
||||||
|
let current = start;
|
||||||
|
let next = firstUnvisited(neighbours[current], visited);
|
||||||
|
while (next !== -1) {
|
||||||
|
points.push(next);
|
||||||
|
visited[next] = 1;
|
||||||
|
current = next;
|
||||||
|
next = firstUnvisited(neighbours[current], visited);
|
||||||
|
}
|
||||||
|
const closesBack = closedWalk && points.length > 2 && neighbours[current].includes(start);
|
||||||
|
return {points, closed: closesBack};
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstUnvisited(candidates: number[], visited: Uint8Array): number {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!visited[candidate]) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {buildClothTopology} from './cloth-topology';
|
||||||
|
import {traceContours} from './intersection-contours';
|
||||||
|
import {IntersectionSet} from './intersection-set';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid 4x2 (vertex id = y * 4 + x). Quad 0 lies flat in z = 0 (triangles 0 and 1).
|
||||||
|
* Quad 2 (triangles 4 and 5) stands on it and crosses z = 0 inside triangle 0.
|
||||||
|
*/
|
||||||
|
const topology = buildClothTopology(4, 2);
|
||||||
|
const positions = new Float32Array([
|
||||||
|
0, 0, 0, 1, /* v0 */ 2, 0, 0, 1, /* v1 */ 0.6, 0.7, -1, 1, /* v2 */ 1.0, 1.3, -1, 1, /* v3 */
|
||||||
|
0, 2, 0, 1, /* v4 */ 2, 2, 0, 1, /* v5 */ 0.6, 0.7, 1, 1, /* v6 */ 1.0, 1.3, 1, 1 /* v7 */
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe('IntersectionSet', () => {
|
||||||
|
it('skips adjacent triangles', () => {
|
||||||
|
const set = new IntersectionSet();
|
||||||
|
expect(set.addTrianglePair(positions, topology, 0, 1)).toBe(0);
|
||||||
|
expect(set.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns two hits into one segment', () => {
|
||||||
|
const set = new IntersectionSet();
|
||||||
|
expect(set.addTrianglePair(positions, topology, 0, 4)).toBe(2);
|
||||||
|
expect(set.count).toBe(2);
|
||||||
|
expect(set.segmentCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a hit once when both triangles of its edge find it', () => {
|
||||||
|
const set = new IntersectionSet();
|
||||||
|
set.addTrianglePair(positions, topology, 0, 4);
|
||||||
|
set.addTrianglePair(positions, topology, 0, 5);
|
||||||
|
expect(set.count).toBe(3);
|
||||||
|
expect(set.segmentCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the hit point on the pierced triangle', () => {
|
||||||
|
const set = new IntersectionSet();
|
||||||
|
set.addTrianglePair(positions, topology, 0, 4);
|
||||||
|
const triangles = Array.from(set.triangle);
|
||||||
|
expect(triangles).toEqual([0, 0]);
|
||||||
|
expect(set.points[2]).toBeCloseTo(0);
|
||||||
|
expect(set.points[5]).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives no segment for triangles that do not touch', () => {
|
||||||
|
const set = new IntersectionSet();
|
||||||
|
expect(set.addTrianglePair(positions, topology, 1, 4)).toBe(0);
|
||||||
|
expect(set.segmentCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('feeds traceContours with one open path across two pairs', () => {
|
||||||
|
const set = new IntersectionSet();
|
||||||
|
set.addTrianglePair(positions, topology, 0, 4);
|
||||||
|
set.addTrianglePair(positions, topology, 0, 5);
|
||||||
|
const contours = traceContours(set);
|
||||||
|
expect(contours.length).toBe(1);
|
||||||
|
expect(contours[0].closed).toBe(false);
|
||||||
|
expect(contours[0].points.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clear() resets hits and segments', () => {
|
||||||
|
const set = new IntersectionSet();
|
||||||
|
set.addTrianglePair(positions, topology, 0, 4);
|
||||||
|
set.clear();
|
||||||
|
expect(set.count).toBe(0);
|
||||||
|
expect(set.segmentCount).toBe(0);
|
||||||
|
set.addTrianglePair(positions, topology, 0, 4);
|
||||||
|
expect(set.count).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import {ClothTopology, trianglesShareVertex} from './cloth-topology';
|
||||||
|
import {EdgeTriangleHit, intersectEdgeTriangle, newEdgeTriangleHit} from './edge-triangle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All edge-triangle hits of one frame and the contour segments between them.
|
||||||
|
* A hit is one edge piercing one triangle. The same hit is found from both triangles that share the edge,
|
||||||
|
* so it is stored once and can be an end of two segments.
|
||||||
|
*/
|
||||||
|
export class IntersectionSet {
|
||||||
|
count = 0;
|
||||||
|
edge: number[] = [];
|
||||||
|
triangle: number[] = [];
|
||||||
|
t: number[] = [];
|
||||||
|
u: number[] = [];
|
||||||
|
v: number[] = [];
|
||||||
|
/** 3 per hit. */
|
||||||
|
points: number[] = [];
|
||||||
|
/** 2 hit indices per segment. */
|
||||||
|
segments: number[] = [];
|
||||||
|
|
||||||
|
private readonly hitByKey = new Map<number, number>();
|
||||||
|
private readonly scratch: EdgeTriangleHit = newEdgeTriangleHit();
|
||||||
|
private readonly pairHits: {edge: number; triangle: number; hit: EdgeTriangleHit}[] = [];
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.count = 0;
|
||||||
|
this.edge.length = 0;
|
||||||
|
this.triangle.length = 0;
|
||||||
|
this.t.length = 0;
|
||||||
|
this.u.length = 0;
|
||||||
|
this.v.length = 0;
|
||||||
|
this.points.length = 0;
|
||||||
|
this.segments.length = 0;
|
||||||
|
this.hitByKey.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
get segmentCount(): number {
|
||||||
|
return this.segments.length / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the 3 edges of each triangle against the other one. Adjacent triangles (shared vertex) are skipped.
|
||||||
|
* Exactly 2 hits give one segment. 1 or 3+ hits (a vertex or an edge lies on the other triangle) drop the pair.
|
||||||
|
* Returns the number of raw hits found, 0 = the triangles do not intersect.
|
||||||
|
*/
|
||||||
|
addTrianglePair(positions: Float32Array, topology: ClothTopology, triA: number, triB: number): number {
|
||||||
|
if (trianglesShareVertex(topology, triA, triB)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let found = 0;
|
||||||
|
found = this.collectHits(positions, topology, triA, triB, found);
|
||||||
|
found = this.collectHits(positions, topology, triB, triA, found);
|
||||||
|
if (found !== 2) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triangleCount = topology.triangles.length / 3;
|
||||||
|
const first = this.registerHit(this.pairHits[0], triangleCount);
|
||||||
|
const second = this.registerHit(this.pairHits[1], triangleCount);
|
||||||
|
this.segments.push(first, second);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tests the edges of `owner` against `target`; keeps the hits in `pairHits` from index `found`. */
|
||||||
|
private collectHits(positions: Float32Array, topology: ClothTopology, owner: number, target: number, found: number): number {
|
||||||
|
const {triangles, triangleEdges, edges} = topology;
|
||||||
|
let total = found;
|
||||||
|
for (let corner = 0; corner < 3; corner++) {
|
||||||
|
const edge = triangleEdges[owner * 3 + corner];
|
||||||
|
const isHit = intersectEdgeTriangle(
|
||||||
|
positions, edges[edge * 2], edges[edge * 2 + 1],
|
||||||
|
triangles[target * 3], triangles[target * 3 + 1], triangles[target * 3 + 2], this.scratch
|
||||||
|
);
|
||||||
|
if (!isHit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (total < this.pairHits.length) {
|
||||||
|
this.pairHits[total].edge = edge;
|
||||||
|
this.pairHits[total].triangle = target;
|
||||||
|
Object.assign(this.pairHits[total].hit, this.scratch);
|
||||||
|
} else {
|
||||||
|
this.pairHits.push({edge, triangle: target, hit: {...this.scratch}});
|
||||||
|
}
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHit(pairHit: {edge: number; triangle: number; hit: EdgeTriangleHit}, triangleCount: number): number {
|
||||||
|
const key = pairHit.edge * triangleCount + pairHit.triangle;
|
||||||
|
const existing = this.hitByKey.get(key);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const index = this.count++;
|
||||||
|
this.hitByKey.set(key, index);
|
||||||
|
this.edge.push(pairHit.edge);
|
||||||
|
this.triangle.push(pairHit.triangle);
|
||||||
|
this.t.push(pairHit.hit.t);
|
||||||
|
this.u.push(pairHit.hit.u);
|
||||||
|
this.v.push(pairHit.hit.v);
|
||||||
|
this.points.push(pairHit.hit.x, pairHit.hit.y, pairHit.hit.z);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {borderProjectionIsSimple, computeTriangleNormals, coneMerge} from './normal-cone';
|
||||||
|
|
||||||
|
/** Vertex layout: [x, y, z, invMass]. */
|
||||||
|
function loop(...points: number[][]): {positions: Float32Array; ids: Uint32Array} {
|
||||||
|
const positions = new Float32Array(points.flatMap(point => [point[0], point[1], point[2], 1]));
|
||||||
|
return {positions, ids: Uint32Array.from(points.map((_, index) => index))};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('normal cone', () => {
|
||||||
|
it('merges two normals 90 degrees apart into a cone of half 45 degrees', () => {
|
||||||
|
const merged = coneMerge([1, 0, 0], 0, [0, 1, 0], 0);
|
||||||
|
expect(merged.half).toBeCloseTo(Math.PI / 4);
|
||||||
|
expect(merged.axis[0]).toBeCloseTo(Math.SQRT1_2);
|
||||||
|
expect(merged.axis[1]).toBeCloseTo(Math.SQRT1_2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the half angle of a part that already covers the other', () => {
|
||||||
|
const merged = coneMerge([0, 0, 1], 1, [0, 0, 1], 0.2);
|
||||||
|
expect(merged.half).toBeCloseTo(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives half PI for opposite axes', () => {
|
||||||
|
expect(coneMerge([0, 0, 1], 0, [0, 0, -1], 0).half).toBe(Math.PI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes unit triangle normals from the winding', () => {
|
||||||
|
const {positions} = loop([0, 0, 0], [1, 0, 0], [0, 1, 0]);
|
||||||
|
const normals = new Float32Array(3);
|
||||||
|
computeTriangleNormals(positions, Uint32Array.from([0, 1, 2]), normals);
|
||||||
|
expect(Array.from(normals)).toEqual([0, 0, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('borderProjectionIsSimple', () => {
|
||||||
|
it('accepts a flat loop', () => {
|
||||||
|
const {positions, ids} = loop([0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]);
|
||||||
|
expect(borderProjectionIsSimple(positions, ids, [0, 0, 1])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a raised loop seen from above', () => {
|
||||||
|
const {positions, ids} = loop([0, 0, 0], [1, 0, 0.5], [1, 1, 0], [0, 1, 0.5]);
|
||||||
|
expect(borderProjectionIsSimple(positions, ids, [0, 0, 1])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a figure-eight loop', () => {
|
||||||
|
const {positions, ids} = loop([0, 0, 0], [1, 1, 0], [1, 0, 0], [0, 1, 0]);
|
||||||
|
expect(borderProjectionIsSimple(positions, ids, [0, 0, 1])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
export interface NormalCone {
|
||||||
|
axis: [number, number, number];
|
||||||
|
half: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_AXIS_LENGTH = 1e-6;
|
||||||
|
|
||||||
|
let projectedScratch = new Float64Array(0);
|
||||||
|
|
||||||
|
/** Unit normal per triangle (vec4 positions, 3 floats per triangle in `out`). Degenerate triangles get (0, 1, 0). */
|
||||||
|
export function computeTriangleNormals(positions: Float32Array, triangles: Uint32Array, out: Float32Array): void {
|
||||||
|
const triangleCount = triangles.length / 3;
|
||||||
|
for (let triangle = 0; triangle < triangleCount; triangle++) {
|
||||||
|
const a = triangles[triangle * 3] * 4;
|
||||||
|
const b = triangles[triangle * 3 + 1] * 4;
|
||||||
|
const c = triangles[triangle * 3 + 2] * 4;
|
||||||
|
const e1x = positions[b] - positions[a];
|
||||||
|
const e1y = positions[b + 1] - positions[a + 1];
|
||||||
|
const e1z = positions[b + 2] - positions[a + 2];
|
||||||
|
const e2x = positions[c] - positions[a];
|
||||||
|
const e2y = positions[c + 1] - positions[a + 1];
|
||||||
|
const e2z = positions[c + 2] - positions[a + 2];
|
||||||
|
const nx = e1y * e2z - e1z * e2y;
|
||||||
|
const ny = e1z * e2x - e1x * e2z;
|
||||||
|
const nz = e1x * e2y - e1y * e2x;
|
||||||
|
const length = Math.hypot(nx, ny, nz);
|
||||||
|
const base = triangle * 3;
|
||||||
|
if (length < MIN_AXIS_LENGTH * MIN_AXIS_LENGTH) {
|
||||||
|
out[base] = 0;
|
||||||
|
out[base + 1] = 1;
|
||||||
|
out[base + 2] = 0;
|
||||||
|
} else {
|
||||||
|
out[base] = nx / length;
|
||||||
|
out[base + 1] = ny / length;
|
||||||
|
out[base + 2] = nz / length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function angleBetween(ax: number, ay: number, az: number, bx: number, by: number, bz: number): number {
|
||||||
|
const dot = ax * bx + ay * by + az * bz;
|
||||||
|
return Math.acos(Math.min(1, Math.max(-1, dot)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges two cones with unit axes into the cone that contains both.
|
||||||
|
* Writes the new axis to `outAxis[outOffset..+2]` and returns the half angle (PI = every direction).
|
||||||
|
*/
|
||||||
|
export function coneMergeInto(
|
||||||
|
axisA: ArrayLike<number>, offsetA: number, halfA: number,
|
||||||
|
axisB: ArrayLike<number>, offsetB: number, halfB: number,
|
||||||
|
outAxis: Float32Array, outOffset: number
|
||||||
|
): number {
|
||||||
|
const ax = axisA[offsetA];
|
||||||
|
const ay = axisA[offsetA + 1];
|
||||||
|
const az = axisA[offsetA + 2];
|
||||||
|
const bx = axisB[offsetB];
|
||||||
|
const by = axisB[offsetB + 1];
|
||||||
|
const bz = axisB[offsetB + 2];
|
||||||
|
const sx = ax + bx;
|
||||||
|
const sy = ay + by;
|
||||||
|
const sz = az + bz;
|
||||||
|
const length = Math.hypot(sx, sy, sz);
|
||||||
|
if (length < MIN_AXIS_LENGTH) {
|
||||||
|
outAxis[outOffset] = ax;
|
||||||
|
outAxis[outOffset + 1] = ay;
|
||||||
|
outAxis[outOffset + 2] = az;
|
||||||
|
return Math.PI;
|
||||||
|
}
|
||||||
|
const mx = sx / length;
|
||||||
|
const my = sy / length;
|
||||||
|
const mz = sz / length;
|
||||||
|
outAxis[outOffset] = mx;
|
||||||
|
outAxis[outOffset + 1] = my;
|
||||||
|
outAxis[outOffset + 2] = mz;
|
||||||
|
const reachA = angleBetween(mx, my, mz, ax, ay, az) + halfA;
|
||||||
|
const reachB = angleBetween(mx, my, mz, bx, by, bz) + halfB;
|
||||||
|
return Math.min(Math.PI, Math.max(reachA, reachB));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coneMerge(axisA: ArrayLike<number>, halfA: number, axisB: ArrayLike<number>, halfB: number): NormalCone {
|
||||||
|
const axis = new Float32Array(3);
|
||||||
|
const half = coneMergeInto(axisA, 0, halfA, axisB, 0, halfB, axis, 0);
|
||||||
|
return {axis: [axis[0], axis[1], axis[2]], half};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Volino 1994: projects the closed border loop onto the plane orthogonal to `axis` and checks that
|
||||||
|
* the projection has no crossing segments. Adjacent segments are skipped. O(B^2).
|
||||||
|
* Only the first `count` ids of `borderVertexIds` are used, so that callers can reuse one buffer.
|
||||||
|
*/
|
||||||
|
export function borderProjectionIsSimple(
|
||||||
|
positions: Float32Array, borderVertexIds: Uint32Array, axis: ArrayLike<number>, count = borderVertexIds.length
|
||||||
|
): boolean {
|
||||||
|
const [ux, uy, uz, vx, vy, vz] = planeBasis(axis[0], axis[1], axis[2]);
|
||||||
|
if (projectedScratch.length < count * 2) {
|
||||||
|
projectedScratch = new Float64Array(count * 2);
|
||||||
|
}
|
||||||
|
const projected = projectedScratch;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const base = borderVertexIds[i] * 4;
|
||||||
|
projected[i * 2] = positions[base] * ux + positions[base + 1] * uy + positions[base + 2] * uz;
|
||||||
|
projected[i * 2 + 1] = positions[base] * vx + positions[base + 1] * vy + positions[base + 2] * vz;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const nextI = (i + 1) % count;
|
||||||
|
for (let j = i + 2; j < count; j++) {
|
||||||
|
const nextJ = (j + 1) % count;
|
||||||
|
if (nextJ === i) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (segmentsCross(projected, i, nextI, j, nextJ)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function planeBasis(x: number, y: number, z: number): [number, number, number, number, number, number] {
|
||||||
|
// Cross with the world axis that is least aligned with `axis`.
|
||||||
|
const useX = Math.abs(x) <= Math.abs(y) && Math.abs(x) <= Math.abs(z);
|
||||||
|
const useY = !useX && Math.abs(y) <= Math.abs(z);
|
||||||
|
const hx = useX ? 1 : 0;
|
||||||
|
const hy = useY ? 1 : 0;
|
||||||
|
const hz = useX || useY ? 0 : 1;
|
||||||
|
let ux = y * hz - z * hy;
|
||||||
|
let uy = z * hx - x * hz;
|
||||||
|
let uz = x * hy - y * hx;
|
||||||
|
const uLength = Math.hypot(ux, uy, uz);
|
||||||
|
ux /= uLength;
|
||||||
|
uy /= uLength;
|
||||||
|
uz /= uLength;
|
||||||
|
const vx = y * uz - z * uy;
|
||||||
|
const vy = z * ux - x * uz;
|
||||||
|
const vz = x * uy - y * ux;
|
||||||
|
return [ux, uy, uz, vx, vy, vz];
|
||||||
|
}
|
||||||
|
|
||||||
|
function orientation(p: Float64Array, a: number, b: number, c: number): number {
|
||||||
|
return (p[b * 2] - p[a * 2]) * (p[c * 2 + 1] - p[a * 2 + 1]) - (p[b * 2 + 1] - p[a * 2 + 1]) * (p[c * 2] - p[a * 2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentsCross(p: Float64Array, a: number, b: number, c: number, d: number): boolean {
|
||||||
|
const o1 = orientation(p, a, b, c);
|
||||||
|
const o2 = orientation(p, a, b, d);
|
||||||
|
const o3 = orientation(p, c, d, a);
|
||||||
|
const o4 = orientation(p, c, d, b);
|
||||||
|
return o1 * o2 < 0 && o3 * o4 < 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {buildClothTopology} from './cloth-topology';
|
||||||
|
import {buildProximityConstraints, PROXIMITY_STRIDE, solveProximityConstraints} from './proximity-constraints';
|
||||||
|
|
||||||
|
const H = 0.2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid 4x2: triangle 0 = vertices 0, 4, 5 (quad 0), triangle 5 = vertices 2, 7, 3 (quad 2). No shared vertex.
|
||||||
|
* Vertex layout [x, y, z, invMass]; the triangle 0 corners are placed explicitly, the rest of the grid is moved far away.
|
||||||
|
*/
|
||||||
|
const topology = buildClothTopology(4, 2);
|
||||||
|
|
||||||
|
function positionsWith(triangle0: number[][], triangle5: number[][], invMass0 = [1, 1, 1], invMass5 = [1, 1, 1]): Float32Array {
|
||||||
|
const positions = new Float32Array(topology.vertexCount * 4);
|
||||||
|
for (let vertex = 0; vertex < topology.vertexCount; vertex++) {
|
||||||
|
positions.set([100 + vertex, 100, 100, 1], vertex * 4);
|
||||||
|
}
|
||||||
|
[0, 4, 5].forEach((vertex, index) => positions.set([...triangle0[index], invMass0[index]], vertex * 4));
|
||||||
|
[2, 7, 3].forEach((vertex, index) => positions.set([...triangle5[index], invMass5[index]], vertex * 4));
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_TRIANGLE = [[0, 0, 0], [4, 0, 0], [0, 0, 4]];
|
||||||
|
// Far away from the base triangle unless a test places one corner above it.
|
||||||
|
const FAR_TRIANGLE = [[50, 50, 50], [60, 50, 50], [50, 60, 50]];
|
||||||
|
|
||||||
|
function distanceAbove(positions: Float32Array, vertex: number): number {
|
||||||
|
return positions[vertex * 4 + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('proximity constraints', () => {
|
||||||
|
it('pushes a point 0.5 h above a triangle out to h', () => {
|
||||||
|
const positions = positionsWith(BASE_TRIANGLE, [[1, 0.5 * H, 1], ...FAR_TRIANGLE.slice(1)]);
|
||||||
|
const constraints = buildProximityConstraints(positions, topology, [0, 5], H);
|
||||||
|
expect(constraints.length).toBeGreaterThan(0);
|
||||||
|
expect(constraints.length % PROXIMITY_STRIDE).toBe(0);
|
||||||
|
|
||||||
|
solveProximityConstraints(positions, constraints, H);
|
||||||
|
|
||||||
|
const point = positions.slice(2 * 4, 2 * 4 + 3);
|
||||||
|
const triangleHeight = positions[1];
|
||||||
|
expect(point[1] - triangleHeight).toBeGreaterThanOrEqual(H - 1e-4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a point at 2 h untouched and builds no constraint for it', () => {
|
||||||
|
const positions = positionsWith(BASE_TRIANGLE, [[1, 2 * H, 1], ...FAR_TRIANGLE.slice(1)]);
|
||||||
|
const before = positions.slice();
|
||||||
|
const constraints = buildProximityConstraints(positions, topology, [0, 5], H);
|
||||||
|
expect(constraints.length).toBe(0);
|
||||||
|
solveProximityConstraints(positions, constraints, H);
|
||||||
|
expect(Array.from(positions)).toEqual(Array.from(before));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not push beyond the thickness', () => {
|
||||||
|
const positions = positionsWith(BASE_TRIANGLE, [[1, 0.5 * H, 1], ...FAR_TRIANGLE.slice(1)]);
|
||||||
|
const constraints = buildProximityConstraints(positions, topology, [0, 5], H);
|
||||||
|
solveProximityConstraints(positions, constraints, H);
|
||||||
|
solveProximityConstraints(positions, constraints, H);
|
||||||
|
const gap = distanceAbove(positions, 2) - distanceAbove(positions, 0);
|
||||||
|
// Several constraints with slightly different normals share the point, so a small overshoot is fine.
|
||||||
|
expect(gap).toBeLessThan(H * 1.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps pinned vertices in place and moves the free point the whole way', () => {
|
||||||
|
const positions = positionsWith(BASE_TRIANGLE, [[1, 0.5 * H, 1], ...FAR_TRIANGLE.slice(1)], [0, 0, 0]);
|
||||||
|
const constraints = buildProximityConstraints(positions, topology, [0, 5], H);
|
||||||
|
solveProximityConstraints(positions, constraints, H);
|
||||||
|
expect(Array.from(positions.slice(0, 3))).toEqual([0, 0, 0]);
|
||||||
|
expect(distanceAbove(positions, 2)).toBeCloseTo(H, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushes two crossing edges apart to h', () => {
|
||||||
|
// Triangle 0 edge 0-4 runs along z at x=0,y=0; the other triangle's edge 2-7 runs along x at height 0.5 h, crossing over it.
|
||||||
|
const positions = positionsWith(
|
||||||
|
[[0, 0, -2], [0, 0, 2], [-2, 0, 0]],
|
||||||
|
[[-1, 0.5 * H, 0], [1, 0.5 * H, 0], [50, 50, 50]]
|
||||||
|
);
|
||||||
|
const constraints = buildProximityConstraints(positions, topology, [0, 5], H);
|
||||||
|
solveProximityConstraints(positions, constraints, H);
|
||||||
|
const edgeHeightA = (distanceAbove(positions, 0) + distanceAbove(positions, 4)) / 2;
|
||||||
|
const edgeHeightB = (distanceAbove(positions, 2) + distanceAbove(positions, 7)) / 2;
|
||||||
|
expect(edgeHeightB - edgeHeightA).toBeGreaterThanOrEqual(H - 1e-3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import {ClothTopology} from './cloth-topology';
|
||||||
|
|
||||||
|
/** [type, i0, i1, i2, i3, w0, w1, w2, w3, nx, ny, nz] */
|
||||||
|
export const PROXIMITY_STRIDE = 12;
|
||||||
|
const POINT_TRIANGLE = 0;
|
||||||
|
const EDGE_EDGE = 1;
|
||||||
|
const MIN_DISTANCE = 1e-9;
|
||||||
|
|
||||||
|
const closestWeights = new Float64Array(3);
|
||||||
|
const closestParameters = new Float64Array(2);
|
||||||
|
const solveIds = new Int32Array(4);
|
||||||
|
const solveCoefficients = new Float64Array(4);
|
||||||
|
|
||||||
|
let constraintBuffer = new Float32Array(PROXIMITY_STRIDE * 1024);
|
||||||
|
let constraintLength = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point-triangle and edge-edge pairs closer than `thickness`, for every candidate triangle pair (2 ids each).
|
||||||
|
* `n` points from the second primitive (triangle / edge 2) to the first (point / edge 1) at build time.
|
||||||
|
* The result is a view on one shared buffer: valid until the next call.
|
||||||
|
*/
|
||||||
|
export function buildProximityConstraints(
|
||||||
|
positions: Float32Array, topology: ClothTopology, candidatePairs: number[], thickness: number
|
||||||
|
): Float32Array {
|
||||||
|
constraintLength = 0;
|
||||||
|
for (let pair = 0; pair < candidatePairs.length; pair += 2) {
|
||||||
|
const triA = candidatePairs[pair];
|
||||||
|
const triB = candidatePairs[pair + 1];
|
||||||
|
addPointTriangleConstraints(positions, topology, triA, triB, thickness);
|
||||||
|
addPointTriangleConstraints(positions, topology, triB, triA, thickness);
|
||||||
|
addEdgeEdgeConstraints(positions, topology, triA, triB, thickness);
|
||||||
|
}
|
||||||
|
return constraintBuffer.subarray(0, constraintLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPointTriangleConstraints(
|
||||||
|
positions: Float32Array, topology: ClothTopology, pointTriangle: number, triangle: number, thickness: number
|
||||||
|
): void {
|
||||||
|
const {triangles} = topology;
|
||||||
|
const t0 = triangles[triangle * 3];
|
||||||
|
const t1 = triangles[triangle * 3 + 1];
|
||||||
|
const t2 = triangles[triangle * 3 + 2];
|
||||||
|
for (let corner = 0; corner < 3; corner++) {
|
||||||
|
const point = triangles[pointTriangle * 3 + corner];
|
||||||
|
closestPointOnTriangle(positions, point, t0, t1, t2, closestWeights);
|
||||||
|
const w0 = closestWeights[0];
|
||||||
|
const w1 = closestWeights[1];
|
||||||
|
const w2 = closestWeights[2];
|
||||||
|
const dx = positions[point * 4] - (w0 * positions[t0 * 4] + w1 * positions[t1 * 4] + w2 * positions[t2 * 4]);
|
||||||
|
const dy = positions[point * 4 + 1] - (w0 * positions[t0 * 4 + 1] + w1 * positions[t1 * 4 + 1] + w2 * positions[t2 * 4 + 1]);
|
||||||
|
const dz = positions[point * 4 + 2] - (w0 * positions[t0 * 4 + 2] + w1 * positions[t1 * 4 + 2] + w2 * positions[t2 * 4 + 2]);
|
||||||
|
pushConstraint(POINT_TRIANGLE, point, t0, t1, t2, 1, w0, w1, w2, dx, dy, dz, thickness);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEdgeEdgeConstraints(
|
||||||
|
positions: Float32Array, topology: ClothTopology, triA: number, triB: number, thickness: number
|
||||||
|
): void {
|
||||||
|
const {triangles} = topology;
|
||||||
|
for (let cornerA = 0; cornerA < 3; cornerA++) {
|
||||||
|
const a0 = triangles[triA * 3 + cornerA];
|
||||||
|
const a1 = triangles[triA * 3 + (cornerA + 1) % 3];
|
||||||
|
for (let cornerB = 0; cornerB < 3; cornerB++) {
|
||||||
|
const b0 = triangles[triB * 3 + cornerB];
|
||||||
|
const b1 = triangles[triB * 3 + (cornerB + 1) % 3];
|
||||||
|
closestPointsOnSegments(positions, a0, a1, b0, b1, closestParameters);
|
||||||
|
const s = closestParameters[0];
|
||||||
|
const t = closestParameters[1];
|
||||||
|
const dx = lerp(positions[a0 * 4], positions[a1 * 4], s) - lerp(positions[b0 * 4], positions[b1 * 4], t);
|
||||||
|
const dy = lerp(positions[a0 * 4 + 1], positions[a1 * 4 + 1], s) - lerp(positions[b0 * 4 + 1], positions[b1 * 4 + 1], t);
|
||||||
|
const dz = lerp(positions[a0 * 4 + 2], positions[a1 * 4 + 2], s) - lerp(positions[b0 * 4 + 2], positions[b1 * 4 + 2], t);
|
||||||
|
pushConstraint(EDGE_EDGE, a0, a1, b0, b1, 1 - s, s, 1 - t, t, dx, dy, dz, thickness);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushConstraint(
|
||||||
|
type: number, id0: number, id1: number, id2: number, id3: number, w0: number, w1: number, w2: number, w3: number,
|
||||||
|
dx: number, dy: number, dz: number, thickness: number
|
||||||
|
): void {
|
||||||
|
const distance = Math.hypot(dx, dy, dz);
|
||||||
|
if (distance >= thickness || distance < MIN_DISTANCE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (constraintLength + PROXIMITY_STRIDE > constraintBuffer.length) {
|
||||||
|
const grown = new Float32Array(constraintBuffer.length * 2);
|
||||||
|
grown.set(constraintBuffer);
|
||||||
|
constraintBuffer = grown;
|
||||||
|
}
|
||||||
|
const buffer = constraintBuffer;
|
||||||
|
const base = constraintLength;
|
||||||
|
buffer[base] = type;
|
||||||
|
buffer[base + 1] = id0;
|
||||||
|
buffer[base + 2] = id1;
|
||||||
|
buffer[base + 3] = id2;
|
||||||
|
buffer[base + 4] = id3;
|
||||||
|
buffer[base + 5] = w0;
|
||||||
|
buffer[base + 6] = w1;
|
||||||
|
buffer[base + 7] = w2;
|
||||||
|
buffer[base + 8] = w3;
|
||||||
|
buffer[base + 9] = dx / distance;
|
||||||
|
buffer[base + 10] = dy / distance;
|
||||||
|
buffer[base + 11] = dz / distance;
|
||||||
|
constraintLength += PROXIMITY_STRIDE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XPBD with compliance 0, weighted by inverse mass (positions[i * 4 + 3]). Pushes only while the pair is closer than `thickness`. */
|
||||||
|
export function solveProximityConstraints(positions: Float32Array, constraints: Float32Array, thickness: number): void {
|
||||||
|
for (let base = 0; base < constraints.length; base += PROXIMITY_STRIDE) {
|
||||||
|
fillSolveTerms(constraints, base);
|
||||||
|
const nx = constraints[base + 9];
|
||||||
|
const ny = constraints[base + 10];
|
||||||
|
const nz = constraints[base + 11];
|
||||||
|
|
||||||
|
let distance = 0;
|
||||||
|
let weightedInverseMass = 0;
|
||||||
|
for (let slot = 0; slot < 4; slot++) {
|
||||||
|
const vertex = solveIds[slot] * 4;
|
||||||
|
const coefficient = solveCoefficients[slot];
|
||||||
|
distance += coefficient * (positions[vertex] * nx + positions[vertex + 1] * ny + positions[vertex + 2] * nz);
|
||||||
|
weightedInverseMass += positions[vertex + 3] * coefficient * coefficient;
|
||||||
|
}
|
||||||
|
if (distance >= thickness || weightedInverseMass <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lambda = (thickness - distance) / weightedInverseMass;
|
||||||
|
for (let slot = 0; slot < 4; slot++) {
|
||||||
|
const vertex = solveIds[slot] * 4;
|
||||||
|
const move = positions[vertex + 3] * solveCoefficients[slot] * lambda;
|
||||||
|
positions[vertex] += nx * move;
|
||||||
|
positions[vertex + 1] += ny * move;
|
||||||
|
positions[vertex + 2] += nz * move;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed coefficients so that the sum of coefficient * position is (first primitive - second primitive). */
|
||||||
|
function fillSolveTerms(constraints: Float32Array, base: number): void {
|
||||||
|
const isPointTriangle = constraints[base] === POINT_TRIANGLE;
|
||||||
|
for (let slot = 0; slot < 4; slot++) {
|
||||||
|
solveIds[slot] = constraints[base + 1 + slot];
|
||||||
|
const weight = constraints[base + 5 + slot];
|
||||||
|
const isSecondPrimitive = isPointTriangle ? slot > 0 : slot > 1;
|
||||||
|
solveCoefficients[slot] = isSecondPrimitive ? -weight : weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ericson, Real-Time Collision Detection 5.1.5: barycentric weights of the closest point of the triangle to `point`. */
|
||||||
|
function closestPointOnTriangle(positions: Float32Array, point: number, a: number, b: number, c: number, out: Float64Array): void {
|
||||||
|
const pa = a * 4;
|
||||||
|
const pb = b * 4;
|
||||||
|
const pc = c * 4;
|
||||||
|
const pp = point * 4;
|
||||||
|
const abx = positions[pb] - positions[pa];
|
||||||
|
const aby = positions[pb + 1] - positions[pa + 1];
|
||||||
|
const abz = positions[pb + 2] - positions[pa + 2];
|
||||||
|
const acx = positions[pc] - positions[pa];
|
||||||
|
const acy = positions[pc + 1] - positions[pa + 1];
|
||||||
|
const acz = positions[pc + 2] - positions[pa + 2];
|
||||||
|
|
||||||
|
const apx = positions[pp] - positions[pa];
|
||||||
|
const apy = positions[pp + 1] - positions[pa + 1];
|
||||||
|
const apz = positions[pp + 2] - positions[pa + 2];
|
||||||
|
const d1 = abx * apx + aby * apy + abz * apz;
|
||||||
|
const d2 = acx * apx + acy * apy + acz * apz;
|
||||||
|
if (d1 <= 0 && d2 <= 0) {
|
||||||
|
return setWeights(out, 1, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bpx = positions[pp] - positions[pb];
|
||||||
|
const bpy = positions[pp + 1] - positions[pb + 1];
|
||||||
|
const bpz = positions[pp + 2] - positions[pb + 2];
|
||||||
|
const d3 = abx * bpx + aby * bpy + abz * bpz;
|
||||||
|
const d4 = acx * bpx + acy * bpy + acz * bpz;
|
||||||
|
if (d3 >= 0 && d4 <= d3) {
|
||||||
|
return setWeights(out, 0, 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vc = d1 * d4 - d3 * d2;
|
||||||
|
if (vc <= 0 && d1 >= 0 && d3 <= 0) {
|
||||||
|
const v = d1 / (d1 - d3);
|
||||||
|
return setWeights(out, 1 - v, v, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpx = positions[pp] - positions[pc];
|
||||||
|
const cpy = positions[pp + 1] - positions[pc + 1];
|
||||||
|
const cpz = positions[pp + 2] - positions[pc + 2];
|
||||||
|
const d5 = abx * cpx + aby * cpy + abz * cpz;
|
||||||
|
const d6 = acx * cpx + acy * cpy + acz * cpz;
|
||||||
|
if (d6 >= 0 && d5 <= d6) {
|
||||||
|
return setWeights(out, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vb = d5 * d2 - d1 * d6;
|
||||||
|
if (vb <= 0 && d2 >= 0 && d6 <= 0) {
|
||||||
|
const w = d2 / (d2 - d6);
|
||||||
|
return setWeights(out, 1 - w, 0, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const va = d3 * d6 - d5 * d4;
|
||||||
|
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) {
|
||||||
|
const w = (d4 - d3) / (d4 - d3 + (d5 - d6));
|
||||||
|
return setWeights(out, 0, 1 - w, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const denominator = 1 / (va + vb + vc);
|
||||||
|
const v = vb * denominator;
|
||||||
|
const w = vc * denominator;
|
||||||
|
return setWeights(out, 1 - v - w, v, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWeights(out: Float64Array, a: number, b: number, c: number): void {
|
||||||
|
out[0] = a;
|
||||||
|
out[1] = b;
|
||||||
|
out[2] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return Math.min(1, Math.max(0, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ericson 5.1.9: parameters s (segment a0-a1) and t (segment b0-b1) of the closest points. */
|
||||||
|
function closestPointsOnSegments(positions: Float32Array, a0: number, a1: number, b0: number, b1: number, out: Float64Array): void {
|
||||||
|
const pa0 = a0 * 4;
|
||||||
|
const pa1 = a1 * 4;
|
||||||
|
const pb0 = b0 * 4;
|
||||||
|
const pb1 = b1 * 4;
|
||||||
|
const d1x = positions[pa1] - positions[pa0];
|
||||||
|
const d1y = positions[pa1 + 1] - positions[pa0 + 1];
|
||||||
|
const d1z = positions[pa1 + 2] - positions[pa0 + 2];
|
||||||
|
const d2x = positions[pb1] - positions[pb0];
|
||||||
|
const d2y = positions[pb1 + 1] - positions[pb0 + 1];
|
||||||
|
const d2z = positions[pb1 + 2] - positions[pb0 + 2];
|
||||||
|
const rx = positions[pa0] - positions[pb0];
|
||||||
|
const ry = positions[pa0 + 1] - positions[pb0 + 1];
|
||||||
|
const rz = positions[pa0 + 2] - positions[pb0 + 2];
|
||||||
|
|
||||||
|
const a = d1x * d1x + d1y * d1y + d1z * d1z;
|
||||||
|
const e = d2x * d2x + d2y * d2y + d2z * d2z;
|
||||||
|
const f = d2x * rx + d2y * ry + d2z * rz;
|
||||||
|
const epsilon = 1e-12;
|
||||||
|
|
||||||
|
let s = 0;
|
||||||
|
let t = 0;
|
||||||
|
if (a <= epsilon && e <= epsilon) {
|
||||||
|
s = 0;
|
||||||
|
t = 0;
|
||||||
|
} else if (a <= epsilon) {
|
||||||
|
t = clamp01(f / e);
|
||||||
|
} else {
|
||||||
|
const c = d1x * rx + d1y * ry + d1z * rz;
|
||||||
|
if (e <= epsilon) {
|
||||||
|
s = clamp01(-c / a);
|
||||||
|
} else {
|
||||||
|
const b = d1x * d2x + d1y * d2y + d1z * d2z;
|
||||||
|
const denominator = a * e - b * b;
|
||||||
|
s = denominator > epsilon ? clamp01((b * f - c * e) / denominator) : 0;
|
||||||
|
t = (b * s + f) / e;
|
||||||
|
if (t < 0) {
|
||||||
|
t = 0;
|
||||||
|
s = clamp01(-c / a);
|
||||||
|
} else if (t > 1) {
|
||||||
|
t = 1;
|
||||||
|
s = clamp01((b - c) / a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[0] = s;
|
||||||
|
out[1] = t;
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import {initialPositions} from '../cloth-icm-scenes';
|
||||||
|
import {ClothBvh} from '../collision/cloth-bvh';
|
||||||
|
import {buildClothTopology, ClothTopology} from '../collision/cloth-topology';
|
||||||
|
import {detectCollisions} from '../collision/collision-detection';
|
||||||
|
import {Contour, traceContours} from '../collision/intersection-contours';
|
||||||
|
import {IntersectionSet} from '../collision/intersection-set';
|
||||||
|
import {
|
||||||
|
accumulateIcmDisplacement, applyAveragedDisplacement, contourDisplacement, contourSides, distributeDisplacement, IcmParams, pairGradient
|
||||||
|
} from './icm-response';
|
||||||
|
|
||||||
|
const PARAMS: IcmParams = {h0: 0.025, g0: 4};
|
||||||
|
|
||||||
|
interface Analysis {
|
||||||
|
topology: ClothTopology;
|
||||||
|
positions: Float32Array;
|
||||||
|
set: IntersectionSet;
|
||||||
|
contours: Contour[];
|
||||||
|
normals: Float32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyze(topology: ClothTopology, positions: Float32Array): Analysis {
|
||||||
|
const bvh = new ClothBvh(topology);
|
||||||
|
bvh.refit(positions);
|
||||||
|
const set = new IntersectionSet();
|
||||||
|
detectCollisions(bvh, positions, topology, 0, set, []);
|
||||||
|
return {topology, positions, set, contours: traceContours(set), normals: bvh.triangleNormals};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tangled scene has a ridge (open contours). These specs need one closed contour, so the back half
|
||||||
|
* of the scene gets a round bump instead.
|
||||||
|
*/
|
||||||
|
function tangled(gridSize: number): Analysis {
|
||||||
|
const topology = buildClothTopology(gridSize, gridSize);
|
||||||
|
const positions = initialPositions('tangled', topology, 0.1, 1);
|
||||||
|
const frontRows = Math.floor(gridSize / 2);
|
||||||
|
const backStart = frontRows * gridSize;
|
||||||
|
let centerY = 0;
|
||||||
|
for (let vertex = backStart; vertex < topology.vertexCount; vertex++) {
|
||||||
|
centerY += positions[vertex * 4 + 1] / (topology.vertexCount - backStart);
|
||||||
|
}
|
||||||
|
const radius = 0.8;
|
||||||
|
for (let vertex = backStart; vertex < topology.vertexCount; vertex++) {
|
||||||
|
const distance = Math.hypot(positions[vertex * 4], positions[vertex * 4 + 1] - centerY);
|
||||||
|
const falloff = Math.max(0, 1 - (distance * distance) / (radius * radius));
|
||||||
|
positions[vertex * 4 + 2] = -0.1 + 0.2 * falloff ** 3;
|
||||||
|
}
|
||||||
|
return analyze(topology, positions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLAP_COLUMNS = 12;
|
||||||
|
const FLAP_SHEET_ROWS = 6;
|
||||||
|
const FLAP_SHEET_VERTICES = FLAP_COLUMNS * FLAP_SHEET_ROWS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two sheets of one grid. Sheet A (rows 0-5) lies in z = 0. Sheet B (rows 6-11) starts behind the lower border of A,
|
||||||
|
* is shifted to the left, and rises through A along a straight line. One open contour: it ends on the left border of A
|
||||||
|
* and on the right border of B. (Two ends on the same sheet would cancel in D: a straight contour has no length gradient
|
||||||
|
* against a relative translation of the sheets.)
|
||||||
|
*/
|
||||||
|
function flap(): Analysis {
|
||||||
|
const topology = buildClothTopology(FLAP_COLUMNS, FLAP_SHEET_ROWS * 2);
|
||||||
|
const positions = new Float32Array(topology.vertexCount * 4);
|
||||||
|
const angle = 0.9;
|
||||||
|
for (let row = 0; row < FLAP_SHEET_ROWS * 2; row++) {
|
||||||
|
for (let column = 0; column < FLAP_COLUMNS; column++) {
|
||||||
|
const base = (row * FLAP_COLUMNS + column) * 4;
|
||||||
|
if (row < FLAP_SHEET_ROWS) {
|
||||||
|
positions.set([(column - 5.5) * 0.1, -row * 0.1, 0, 1], base);
|
||||||
|
} else {
|
||||||
|
const along = (row - FLAP_SHEET_ROWS) * 0.1;
|
||||||
|
positions.set([(column - 7) * 0.1 + 0.0371, -0.5137 + along * Math.cos(angle), -0.1 + along * Math.sin(angle), 1], base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return analyze(topology, positions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSheetA(vertex: number): boolean {
|
||||||
|
return vertex < FLAP_SHEET_VERTICES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sub(a: ArrayLike<number>, b: ArrayLike<number>): number[] {
|
||||||
|
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dot(a: ArrayLike<number>, b: ArrayLike<number>): number {
|
||||||
|
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
function displacementOf(analysis: Analysis, contour: Contour, params = PARAMS): Float32Array {
|
||||||
|
const sides = contourSides(analysis.set, contour, analysis.topology);
|
||||||
|
const out = new Float32Array(3);
|
||||||
|
contourDisplacement(analysis.positions, analysis.topology, analysis.normals, analysis.set, contour, sides, params, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function accumulate(analysis: Analysis, params = PARAMS): {delta: Float32Array; count: Float32Array} {
|
||||||
|
const delta = new Float32Array(analysis.topology.vertexCount * 3);
|
||||||
|
const count = new Float32Array(analysis.topology.vertexCount);
|
||||||
|
accumulateIcmDisplacement(analysis.positions, analysis.topology, analysis.normals, analysis.set, analysis.contours, params, delta, count);
|
||||||
|
return {delta, count};
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalSegmentLength(set: IntersectionSet): number {
|
||||||
|
let length = 0;
|
||||||
|
for (let segment = 0; segment < set.segmentCount; segment++) {
|
||||||
|
const a = set.segments[segment * 2];
|
||||||
|
const b = set.segments[segment * 2 + 1];
|
||||||
|
length += Math.hypot(set.points[a * 3] - set.points[b * 3], set.points[a * 3 + 1] - set.points[b * 3 + 1], set.points[a * 3 + 2] - set.points[b * 3 + 2]);
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pairGradient', () => {
|
||||||
|
// Grid 4x2: triangle 0 (vertices 0, 4, 5) lies in z = 0; triangle 4 (vertices 2, 6, 7) crosses it with two hits.
|
||||||
|
const topology = buildClothTopology(4, 2);
|
||||||
|
|
||||||
|
function crossing(swapEdgeEnds: boolean): Analysis {
|
||||||
|
const positions = new Float32Array([
|
||||||
|
0, 0, 0, 1, 2, 0, 0, 1, 0.6, 0.7, -1, 1, 1.0, 1.3, -1, 1,
|
||||||
|
0, 2, 0, 1, 2, 2, 0, 1, 0.6, 0.7, 1, 1, 1.0, 1.3, 1, 1
|
||||||
|
]);
|
||||||
|
if (swapEdgeEnds) {
|
||||||
|
const first = positions.slice(2 * 4, 2 * 4 + 3);
|
||||||
|
positions.copyWithin(2 * 4, 6 * 4, 6 * 4 + 3);
|
||||||
|
positions.set(first, 6 * 4);
|
||||||
|
}
|
||||||
|
const set = new IntersectionSet();
|
||||||
|
set.addTrianglePair(positions, topology, 0, 4);
|
||||||
|
const normals = new Float32Array(topology.triangles.length);
|
||||||
|
const bvh = new ClothBvh(topology);
|
||||||
|
bvh.refit(positions);
|
||||||
|
normals.set(bvh.triangleNormals);
|
||||||
|
return {topology, positions, set, contours: [], normals};
|
||||||
|
}
|
||||||
|
|
||||||
|
function gradientOf(analysis: Analysis, hit: number, mate: number, normalSign = 1): Float32Array {
|
||||||
|
const normals = analysis.normals.map(value => value * normalSign);
|
||||||
|
const out = new Float32Array(3);
|
||||||
|
pairGradient(analysis.positions, analysis.topology, normals, analysis.set, hit, mate, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the contour end moves along the collision line when the edge is displaced by `move`. */
|
||||||
|
function movedHitPoint(analysis: Analysis, hit: number, move: Float32Array, normal: number[]): number[] {
|
||||||
|
const edge = analysis.set.edge[hit];
|
||||||
|
const a = analysis.topology.edges[edge * 2] * 4;
|
||||||
|
const b = analysis.topology.edges[edge * 2 + 1] * 4;
|
||||||
|
const e = sub(analysis.positions.subarray(b, b + 3), analysis.positions.subarray(a, a + 3));
|
||||||
|
const slide = dot(move, normal) / dot(e, normal);
|
||||||
|
return [move[0] - slide * e[0], move[1] - slide * e[1], move[2] - slide * e[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('moves the contour end towards its mate when the edge follows G', () => {
|
||||||
|
const analysis = crossing(false);
|
||||||
|
const g = gradientOf(analysis, 0, 1);
|
||||||
|
const toMate = sub(analysis.set.points.slice(3, 6), analysis.set.points.slice(0, 3));
|
||||||
|
const normal = Array.from(analysis.normals.subarray(0, 3));
|
||||||
|
const progress = movedHitPoint(analysis, 0, g, normal);
|
||||||
|
expect(dot(progress, toMate)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the same G for a reversed triangle normal and a reversed edge', () => {
|
||||||
|
const base = crossing(false);
|
||||||
|
const flipped = crossing(true);
|
||||||
|
const reference = gradientOf(base, 0, 1);
|
||||||
|
const reversedNormal = gradientOf(base, 0, 1, -1);
|
||||||
|
const reversedEdge = gradientOf(flipped, 0, 1);
|
||||||
|
for (let axis = 0; axis < 3; axis++) {
|
||||||
|
expect(reversedNormal[axis]).toBeCloseTo(reference[axis], 5);
|
||||||
|
expect(reversedEdge[axis]).toBeCloseTo(reference[axis], 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives opposite R parts for the two ends of one segment and stays finite', () => {
|
||||||
|
const analysis = crossing(false);
|
||||||
|
const g01 = gradientOf(analysis, 0, 1);
|
||||||
|
const g10 = gradientOf(analysis, 1, 0);
|
||||||
|
expect(g01.every(Number.isFinite)).toBe(true);
|
||||||
|
expect(g10.every(Number.isFinite)).toBe(true);
|
||||||
|
expect(Math.hypot(g01[0], g01[1], g01[2])).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('contourSides', () => {
|
||||||
|
it('splits a closed loop into the two sheets that cross', () => {
|
||||||
|
const analysis = tangled(30);
|
||||||
|
expect(analysis.contours.length).toBeGreaterThan(0);
|
||||||
|
const frontRows = 15;
|
||||||
|
for (const contour of analysis.contours) {
|
||||||
|
const sides = contourSides(analysis.set, contour, analysis.topology);
|
||||||
|
expect(sides.includes(0)).toBe(true);
|
||||||
|
expect(sides.includes(1)).toBe(true);
|
||||||
|
const sheetOfSide = new Map<number, boolean>();
|
||||||
|
contour.points.forEach((hit, index) => {
|
||||||
|
const firstVertex = analysis.topology.edges[analysis.set.edge[hit] * 2];
|
||||||
|
const isFront = Math.floor(firstVertex / 30) < frontRows;
|
||||||
|
const known = sheetOfSide.get(sides[index]);
|
||||||
|
expect(known === undefined || known === isFront).toBe(true);
|
||||||
|
sheetOfSide.set(sides[index], isFront);
|
||||||
|
});
|
||||||
|
expect(sheetOfSide.get(0)).not.toBe(sheetOfSide.get(1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the edges of each sheet on their own side along an open contour', () => {
|
||||||
|
const analysis = flap();
|
||||||
|
expect(analysis.contours.length).toBe(1);
|
||||||
|
const [contour] = analysis.contours;
|
||||||
|
expect(contour.closed).toBe(false);
|
||||||
|
const sides = contourSides(analysis.set, contour, analysis.topology);
|
||||||
|
const sideOfSheetA = new Set<number>();
|
||||||
|
const sideOfSheetB = new Set<number>();
|
||||||
|
contour.points.forEach((hit, index) => {
|
||||||
|
const vertex = analysis.topology.edges[analysis.set.edge[hit] * 2];
|
||||||
|
(isSheetA(vertex) ? sideOfSheetA : sideOfSheetB).add(sides[index]);
|
||||||
|
});
|
||||||
|
expect(sideOfSheetA.size).toBe(1);
|
||||||
|
expect(sideOfSheetB.size).toBe(1);
|
||||||
|
expect([...sideOfSheetA][0]).not.toBe([...sideOfSheetB][0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('contourDisplacement', () => {
|
||||||
|
it('stays below h0 and reaches h0 when g0 is small against |D|', () => {
|
||||||
|
const analysis = flap();
|
||||||
|
const [contour] = analysis.contours;
|
||||||
|
const smoothed = displacementOf(analysis, contour);
|
||||||
|
const sharp = displacementOf(analysis, contour, {h0: PARAMS.h0, g0: 1e-6});
|
||||||
|
expect(Math.hypot(smoothed[0], smoothed[1], smoothed[2])).toBeLessThan(PARAMS.h0);
|
||||||
|
expect(Math.hypot(sharp[0], sharp[1], sharp[2])).toBeCloseTo(PARAMS.h0, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a longer H for a larger |D| (shorter g0 effect)', () => {
|
||||||
|
const analysis = flap();
|
||||||
|
const [contour] = analysis.contours;
|
||||||
|
const strong = displacementOf(analysis, contour, {h0: PARAMS.h0, g0: 0.5});
|
||||||
|
const weak = displacementOf(analysis, contour, {h0: PARAMS.h0, g0: 50});
|
||||||
|
expect(Math.hypot(strong[0], strong[1], strong[2])).toBeGreaterThan(Math.hypot(weak[0], weak[1], weak[2]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accumulateIcmDisplacement', () => {
|
||||||
|
it('shrinks the closed contour of the tangled scene', () => {
|
||||||
|
const analysis = tangled(30);
|
||||||
|
expect(analysis.contours.some(contour => contour.closed)).toBe(true);
|
||||||
|
const before = totalSegmentLength(analysis.set);
|
||||||
|
const {delta, count} = accumulate(analysis);
|
||||||
|
applyAveragedDisplacement(analysis.positions, delta, count, 1);
|
||||||
|
|
||||||
|
const after = totalSegmentLength(analyze(analysis.topology, analysis.positions).set);
|
||||||
|
expect(after).toBeLessThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves every touched particle along one common direction (the contour has one H)', () => {
|
||||||
|
const analysis = tangled(30);
|
||||||
|
const closed = analysis.contours.filter(contour => contour.closed);
|
||||||
|
expect(analysis.contours.length).toBe(closed.length);
|
||||||
|
const single = {...analysis, contours: [closed[0]]};
|
||||||
|
const {delta, count} = accumulate(single);
|
||||||
|
const h = displacementOf(single, closed[0]);
|
||||||
|
|
||||||
|
let touched = 0;
|
||||||
|
for (let vertex = 0; vertex < count.length; vertex++) {
|
||||||
|
if (count[vertex] === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
touched++;
|
||||||
|
const move = [delta[vertex * 3], delta[vertex * 3 + 1], delta[vertex * 3 + 2]];
|
||||||
|
const cross = [
|
||||||
|
move[1] * h[2] - move[2] * h[1],
|
||||||
|
move[2] * h[0] - move[0] * h[2],
|
||||||
|
move[0] * h[1] - move[1] * h[0]
|
||||||
|
];
|
||||||
|
expect(Math.hypot(cross[0], cross[1], cross[2])).toBeLessThan(1e-6);
|
||||||
|
}
|
||||||
|
expect(touched).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the two sheets of a straight open contour in opposite directions, everywhere along it', () => {
|
||||||
|
const analysis = flap();
|
||||||
|
const [contour] = analysis.contours;
|
||||||
|
const h = displacementOf(analysis, contour);
|
||||||
|
const {delta, count} = accumulate(analysis);
|
||||||
|
|
||||||
|
const alongH = (vertex: number): number => dot([delta[vertex * 3], delta[vertex * 3 + 1], delta[vertex * 3 + 2]], h);
|
||||||
|
const touchedA: number[] = [];
|
||||||
|
const touchedB: number[] = [];
|
||||||
|
for (let vertex = 0; vertex < count.length; vertex++) {
|
||||||
|
if (count[vertex] > 0) {
|
||||||
|
(isSheetA(vertex) ? touchedA : touchedB).push(vertex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(touchedA.length).toBeGreaterThan(0);
|
||||||
|
expect(touchedB.length).toBeGreaterThan(0);
|
||||||
|
const signA = Math.sign(alongH(touchedA[0]));
|
||||||
|
expect(signA).not.toBe(0);
|
||||||
|
for (const vertex of touchedA) {
|
||||||
|
expect(Math.sign(alongH(vertex))).toBe(signA);
|
||||||
|
}
|
||||||
|
for (const vertex of touchedB) {
|
||||||
|
expect(Math.sign(alongH(vertex))).toBe(-signA);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives edge and triangle of every hit opposite displacements that add up to H', () => {
|
||||||
|
const analysis = flap();
|
||||||
|
const [contour] = analysis.contours;
|
||||||
|
const h = displacementOf(analysis, contour);
|
||||||
|
const hit = contour.points[Math.floor(contour.points.length / 2)];
|
||||||
|
const delta = new Float32Array(analysis.topology.vertexCount * 3);
|
||||||
|
const count = new Float32Array(analysis.topology.vertexCount);
|
||||||
|
distributeDisplacement(analysis.positions, analysis.topology, analysis.set, hit, h, 1, delta, count);
|
||||||
|
|
||||||
|
const sum = (vertices: ArrayLike<number>): number[] => {
|
||||||
|
const total = [0, 0, 0];
|
||||||
|
for (const vertex of Array.from(vertices)) {
|
||||||
|
for (let axis = 0; axis < 3; axis++) {
|
||||||
|
total[axis] += delta[vertex * 3 + axis];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
const edge = analysis.set.edge[hit];
|
||||||
|
const edgeMove = sum(analysis.topology.edges.subarray(edge * 2, edge * 2 + 2));
|
||||||
|
const triangle = analysis.set.triangle[hit];
|
||||||
|
const triangleMove = sum(analysis.topology.triangles.subarray(triangle * 3, triangle * 3 + 3));
|
||||||
|
for (let axis = 0; axis < 3; axis++) {
|
||||||
|
expect(edgeMove[axis] - triangleMove[axis]).toBeCloseTo(h[axis], 6);
|
||||||
|
expect(edgeMove[axis] * triangleMove[axis]).toBeLessThanOrEqual(1e-12);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never moves a pinned particle but still moves its partner', () => {
|
||||||
|
const analysis = flap();
|
||||||
|
const first = accumulate(analysis);
|
||||||
|
const pinned = first.count.findIndex(value => value > 0);
|
||||||
|
analysis.positions[pinned * 4 + 3] = 0;
|
||||||
|
|
||||||
|
const second = accumulate(analysis);
|
||||||
|
expect(second.count[pinned]).toBe(0);
|
||||||
|
expect(Math.hypot(second.delta[pinned * 3], second.delta[pinned * 3 + 1], second.delta[pinned * 3 + 2])).toBe(0);
|
||||||
|
const others = Array.from(second.count).filter(value => value > 0).length;
|
||||||
|
expect(others).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the whole H to the edge when the triangle is fully pinned', () => {
|
||||||
|
const analysis = flap();
|
||||||
|
const [contour] = analysis.contours;
|
||||||
|
const h = displacementOf(analysis, contour);
|
||||||
|
const hit = contour.points[1];
|
||||||
|
const triangle = analysis.set.triangle[hit];
|
||||||
|
const triangleVertices = Array.from(analysis.topology.triangles.subarray(triangle * 3, triangle * 3 + 3));
|
||||||
|
for (const vertex of triangleVertices) {
|
||||||
|
analysis.positions[vertex * 4 + 3] = 0;
|
||||||
|
}
|
||||||
|
const delta = new Float32Array(analysis.topology.vertexCount * 3);
|
||||||
|
const count = new Float32Array(analysis.topology.vertexCount);
|
||||||
|
distributeDisplacement(analysis.positions, analysis.topology, analysis.set, hit, h, 1, delta, count);
|
||||||
|
|
||||||
|
const edge = analysis.set.edge[hit];
|
||||||
|
for (let axis = 0; axis < 3; axis++) {
|
||||||
|
const edgeSum = delta[analysis.topology.edges[edge * 2] * 3 + axis] + delta[analysis.topology.edges[edge * 2 + 1] * 3 + axis];
|
||||||
|
expect(edgeSum).toBeCloseTo(h[axis], 6);
|
||||||
|
}
|
||||||
|
for (const vertex of triangleVertices) {
|
||||||
|
expect(count[vertex]).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyAveragedDisplacement', () => {
|
||||||
|
it('averages a particle that is in several pairs and skips particles without a count', () => {
|
||||||
|
const positions = new Float32Array([0, 0, 0, 1, 5, 5, 5, 1]);
|
||||||
|
const delta = new Float32Array([0.3, 0, 0, 9, 9, 9]);
|
||||||
|
const count = new Float32Array([3, 0]);
|
||||||
|
applyAveragedDisplacement(positions, delta, count, 0.5);
|
||||||
|
expect(positions[0]).toBeCloseTo(0.05);
|
||||||
|
expect(Array.from(positions.slice(4, 7))).toEqual([5, 5, 5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import {Contour} from '../collision/intersection-contours';
|
||||||
|
import {IntersectionSet} from '../collision/intersection-set';
|
||||||
|
import {ClothTopology} from '../collision/cloth-topology';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intersection Contour Minimization (Volino and Magnenat-Thalmann 2006), global scheme.
|
||||||
|
* Formulas follow the diploma thesis of Dahm (2010), file Dahm2010-Diplomarbeit.pdf of the diploma project.
|
||||||
|
* Page numbers are the printed ones (PDF page = printed + 6).
|
||||||
|
*
|
||||||
|
* Per contour: a gradient G for every segment end, one common displacement H for the whole contour,
|
||||||
|
* and a spread of H over the particles of every pierced triangle and piercing edge.
|
||||||
|
*/
|
||||||
|
export interface IcmParams {
|
||||||
|
/** Maximum displacement of one contour per frame (h0). */
|
||||||
|
h0: number;
|
||||||
|
/** Smooths H for short contours (g0). */
|
||||||
|
g0: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_LENGTH = 1e-12;
|
||||||
|
|
||||||
|
const gradientScratch = new Float32Array(3);
|
||||||
|
const distanceSquaredScratch = new Float64Array(3);
|
||||||
|
const distanceShareScratch = new Float64Array(3);
|
||||||
|
const edgeVertexScratch = new Int32Array(2);
|
||||||
|
const triangleVertexScratch = new Int32Array(3);
|
||||||
|
|
||||||
|
/** Side 0 or 1 per contour point: which of the two polygon chains of a self-contour the hit's edge belongs to. */
|
||||||
|
export function contourSides(set: IntersectionSet, contour: Contour, topology: ClothTopology): Int8Array {
|
||||||
|
// Diploma 5.1 p. 42, 4.1.4 p. 30-31. The two triangles of a pair never share a vertex, so two hits of one
|
||||||
|
// segment are on the same side exactly when their edges belong to the same triangle (share a vertex).
|
||||||
|
const sides = new Int8Array(contour.points.length);
|
||||||
|
for (let index = 1; index < contour.points.length; index++) {
|
||||||
|
const previousEdge = set.edge[contour.points[index - 1]];
|
||||||
|
const edge = set.edge[contour.points[index]];
|
||||||
|
const sameSide = edgesShareVertex(topology, previousEdge, edge);
|
||||||
|
sides[index] = sameSide ? sides[index - 1] : 1 - sides[index - 1];
|
||||||
|
}
|
||||||
|
return sides;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgesShareVertex(topology: ClothTopology, edgeA: number, edgeB: number): boolean {
|
||||||
|
const {edges} = topology;
|
||||||
|
const a0 = edges[edgeA * 2];
|
||||||
|
const a1 = edges[edgeA * 2 + 1];
|
||||||
|
const b0 = edges[edgeB * 2];
|
||||||
|
const b1 = edges[edgeB * 2 + 1];
|
||||||
|
return a0 === b0 || a0 === b1 || a1 === b0 || a1 === b1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gradient G of one segment end (diploma 3.5.3 p. 19, Volino's formula): the direction in which the edge E of the hit
|
||||||
|
* moves relative to the pierced triangle with unit normal N so that the contour gets shorter.
|
||||||
|
* G = R - 2 * (E . R) / (E . N) * N
|
||||||
|
* R is the unit direction of the collision line from the hit point towards the other end of the segment (`mate`).
|
||||||
|
* The mate lies in the triangle that owns E, so R points from the edge into that triangle (the orientation check of the diploma).
|
||||||
|
* E and N may point either way: both flip together and G stays the same.
|
||||||
|
*/
|
||||||
|
export function pairGradient(
|
||||||
|
positions: Float32Array, topology: ClothTopology, triangleNormals: Float32Array,
|
||||||
|
set: IntersectionSet, hit: number, mate: number, out: Float32Array
|
||||||
|
): void {
|
||||||
|
const edge = set.edge[hit];
|
||||||
|
const vertexA = topology.edges[edge * 2] * 4;
|
||||||
|
const vertexB = topology.edges[edge * 2 + 1] * 4;
|
||||||
|
const ex = positions[vertexB] - positions[vertexA];
|
||||||
|
const ey = positions[vertexB + 1] - positions[vertexA + 1];
|
||||||
|
const ez = positions[vertexB + 2] - positions[vertexA + 2];
|
||||||
|
|
||||||
|
let rx = set.points[mate * 3] - set.points[hit * 3];
|
||||||
|
let ry = set.points[mate * 3 + 1] - set.points[hit * 3 + 1];
|
||||||
|
let rz = set.points[mate * 3 + 2] - set.points[hit * 3 + 2];
|
||||||
|
const rLength = Math.hypot(rx, ry, rz);
|
||||||
|
if (rLength < MIN_LENGTH) {
|
||||||
|
out[0] = 0;
|
||||||
|
out[1] = 0;
|
||||||
|
out[2] = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rx /= rLength;
|
||||||
|
ry /= rLength;
|
||||||
|
rz /= rLength;
|
||||||
|
|
||||||
|
const normalBase = set.triangle[hit] * 3;
|
||||||
|
const nx = triangleNormals[normalBase];
|
||||||
|
const ny = triangleNormals[normalBase + 1];
|
||||||
|
const nz = triangleNormals[normalBase + 2];
|
||||||
|
const edgeDotNormal = ex * nx + ey * ny + ez * nz;
|
||||||
|
const edgeLength = Math.hypot(ex, ey, ez);
|
||||||
|
if (Math.abs(edgeDotNormal) < MIN_LENGTH * edgeLength) {
|
||||||
|
out[0] = rx;
|
||||||
|
out[1] = ry;
|
||||||
|
out[2] = rz;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const factor = (2 * (ex * rx + ey * ry + ez * rz)) / edgeDotNormal;
|
||||||
|
out[0] = rx - factor * nx;
|
||||||
|
out[1] = ry - factor * ny;
|
||||||
|
out[2] = rz - factor * nz;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displacement H of one contour (diploma 3.5.5 p. 21, 5.1 p. 42, 3.5.4 p. 21, 6.4.2 p. 61-62).
|
||||||
|
* D = sum of s_i * G_i over all segment ends of the contour, s_i = +1 on side 0 and -1 on side 1
|
||||||
|
* H = h0 * D / sqrt(|D|^2 + g0^2)
|
||||||
|
* |H| stays below h0 and grows towards h0 with |D|. All particles of one contour move along the same direction.
|
||||||
|
*/
|
||||||
|
export function contourDisplacement(
|
||||||
|
positions: Float32Array, topology: ClothTopology, triangleNormals: Float32Array,
|
||||||
|
set: IntersectionSet, contour: Contour, sides: Int8Array, params: IcmParams, out: Float32Array
|
||||||
|
): void {
|
||||||
|
let dx = 0;
|
||||||
|
let dy = 0;
|
||||||
|
let dz = 0;
|
||||||
|
const {points} = contour;
|
||||||
|
const segmentCount = contour.closed ? points.length : points.length - 1;
|
||||||
|
for (let index = 0; index < segmentCount; index++) {
|
||||||
|
const nextIndex = (index + 1) % points.length;
|
||||||
|
const hitA = points[index];
|
||||||
|
const hitB = points[nextIndex];
|
||||||
|
const signA = sides[index] === 0 ? 1 : -1;
|
||||||
|
const signB = sides[nextIndex] === 0 ? 1 : -1;
|
||||||
|
|
||||||
|
pairGradient(positions, topology, triangleNormals, set, hitA, hitB, gradientScratch);
|
||||||
|
dx += signA * gradientScratch[0];
|
||||||
|
dy += signA * gradientScratch[1];
|
||||||
|
dz += signA * gradientScratch[2];
|
||||||
|
|
||||||
|
pairGradient(positions, topology, triangleNormals, set, hitB, hitA, gradientScratch);
|
||||||
|
dx += signB * gradientScratch[0];
|
||||||
|
dy += signB * gradientScratch[1];
|
||||||
|
dz += signB * gradientScratch[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = params.h0 / Math.sqrt(dx * dx + dy * dy + dz * dz + params.g0 * params.g0);
|
||||||
|
out[0] = dx * scale;
|
||||||
|
out[1] = dy * scale;
|
||||||
|
out[2] = dz * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spreads the impulse I = sign * H of one hit over the particles of its edge and its triangle (diploma 3.6 p. 25, 4.1.5 p. 31-33).
|
||||||
|
* The edge gets +I and the triangle -I, so the two primitives move apart by H relative to each other.
|
||||||
|
* 1. Split between edge and triangle by the sum of inverse masses (p. 32): with equal masses the triangle gets 3/5 and the edge 2/5. A fully pinned primitive gets nothing.
|
||||||
|
* 2. Inside a primitive, particle x gets f_x = (sM_x + sD_x) / 2, the mean of its mass share and its distance share
|
||||||
|
* (nearer to the hit point = more). Pinned particles get 0, the distance shares are renormalised over the free ones.
|
||||||
|
* `outDelta` (3 per vertex) and `outCount` collect the result; see applyAveragedDisplacement.
|
||||||
|
*/
|
||||||
|
export function distributeDisplacement(
|
||||||
|
positions: Float32Array, topology: ClothTopology, set: IntersectionSet, hit: number,
|
||||||
|
h: Float32Array, sign: number, outDelta: Float32Array, outCount: Float32Array
|
||||||
|
): void {
|
||||||
|
const edge = set.edge[hit];
|
||||||
|
const triangle = set.triangle[hit];
|
||||||
|
const edgeVertices = edgeVertexScratch;
|
||||||
|
edgeVertices[0] = topology.edges[edge * 2];
|
||||||
|
edgeVertices[1] = topology.edges[edge * 2 + 1];
|
||||||
|
const triangleVertices = triangleVertexScratch;
|
||||||
|
triangleVertices.set(topology.triangles.subarray(triangle * 3, triangle * 3 + 3));
|
||||||
|
const edgeInverseMass = inverseMassOf(positions, edgeVertices);
|
||||||
|
const triangleInverseMass = inverseMassOf(positions, triangleVertices);
|
||||||
|
const totalInverseMass = edgeInverseMass + triangleInverseMass;
|
||||||
|
if (totalInverseMass <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const impulseX = sign * h[0];
|
||||||
|
const impulseY = sign * h[1];
|
||||||
|
const impulseZ = sign * h[2];
|
||||||
|
const edgeShare = edgeInverseMass / totalInverseMass;
|
||||||
|
const triangleShare = triangleInverseMass / totalInverseMass;
|
||||||
|
const hitBase = hit * 3;
|
||||||
|
|
||||||
|
addToPrimitive(positions, set.points, hitBase, edgeVertices, edgeShare, impulseX, impulseY, impulseZ, outDelta, outCount);
|
||||||
|
addToPrimitive(positions, set.points, hitBase, triangleVertices, -triangleShare, impulseX, impulseY, impulseZ, outDelta, outCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inverseMassOf(positions: Float32Array, vertices: Int32Array): number {
|
||||||
|
let sum = 0;
|
||||||
|
for (const vertex of vertices) {
|
||||||
|
sum += positions[vertex * 4 + 3];
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToPrimitive(
|
||||||
|
positions: Float32Array, points: number[], hitBase: number, vertices: Int32Array, primitiveShare: number,
|
||||||
|
impulseX: number, impulseY: number, impulseZ: number, outDelta: Float32Array, outCount: Float32Array
|
||||||
|
): void {
|
||||||
|
if (primitiveShare === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hitX = points[hitBase];
|
||||||
|
const hitY = points[hitBase + 1];
|
||||||
|
const hitZ = points[hitBase + 2];
|
||||||
|
const count = vertices.length;
|
||||||
|
|
||||||
|
let inverseMassSum = 0;
|
||||||
|
const distancesSquared = distanceSquaredScratch;
|
||||||
|
let distanceSum = 0;
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
const base = vertices[index] * 4;
|
||||||
|
const dx = positions[base] - hitX;
|
||||||
|
const dy = positions[base + 1] - hitY;
|
||||||
|
const dz = positions[base + 2] - hitZ;
|
||||||
|
const distanceSquared = dx * dx + dy * dy + dz * dz;
|
||||||
|
distancesSquared[index] = distanceSquared;
|
||||||
|
distanceSum += distanceSquared;
|
||||||
|
inverseMassSum += positions[base + 3];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance share: edge = the other particle's distance, triangle = half of the other two (p. 32-33).
|
||||||
|
const distanceShares = distanceShareScratch;
|
||||||
|
let freeDistanceShareSum = 0;
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
const share = distanceSum > 0 ? (distanceSum - distancesSquared[index]) / ((count - 1) * distanceSum) : 1 / count;
|
||||||
|
distanceShares[index] = positions[vertices[index] * 4 + 3] > 0 ? share : 0;
|
||||||
|
freeDistanceShareSum += distanceShares[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
const vertex = vertices[index];
|
||||||
|
const inverseMass = positions[vertex * 4 + 3];
|
||||||
|
if (inverseMass <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const massShare = inverseMass / inverseMassSum;
|
||||||
|
const distanceShare = freeDistanceShareSum > 0 ? distanceShares[index] / freeDistanceShareSum : massShare;
|
||||||
|
const factor = primitiveShare * (massShare + distanceShare) / 2;
|
||||||
|
outDelta[vertex * 3] += factor * impulseX;
|
||||||
|
outDelta[vertex * 3 + 1] += factor * impulseY;
|
||||||
|
outDelta[vertex * 3 + 2] += factor * impulseZ;
|
||||||
|
outCount[vertex] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs sides, displacement and distribution for every contour. `outDelta` and `outCount` must be zeroed by the caller. */
|
||||||
|
export function accumulateIcmDisplacement(
|
||||||
|
positions: Float32Array, topology: ClothTopology, triangleNormals: Float32Array,
|
||||||
|
set: IntersectionSet, contours: Contour[], params: IcmParams, outDelta: Float32Array, outCount: Float32Array
|
||||||
|
): void {
|
||||||
|
const displacement = new Float32Array(3);
|
||||||
|
for (const contour of contours) {
|
||||||
|
const sides = contourSides(set, contour, topology);
|
||||||
|
contourDisplacement(positions, topology, triangleNormals, set, contour, sides, params, displacement);
|
||||||
|
for (let index = 0; index < contour.points.length; index++) {
|
||||||
|
const sign = sides[index] === 0 ? 1 : -1;
|
||||||
|
distributeDisplacement(positions, topology, set, contour.points[index], displacement, sign, outDelta, outCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A particle in many pairs is averaged, not summed: positions += scale * delta / count (diploma p. 29, the result is stored
|
||||||
|
* per particle and applied once). Pinned particles never have a count.
|
||||||
|
*/
|
||||||
|
export function applyAveragedDisplacement(positions: Float32Array, delta: Float32Array, count: Float32Array, scale: number): void {
|
||||||
|
for (let vertex = 0; vertex < count.length; vertex++) {
|
||||||
|
if (count[vertex] <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const factor = scale / count[vertex];
|
||||||
|
positions[vertex * 4] += delta[vertex * 3] * factor;
|
||||||
|
positions[vertex * 4 + 1] += delta[vertex * 3 + 1] * factor;
|
||||||
|
positions[vertex * 4 + 2] += delta[vertex * 3 + 2] * factor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,13 +16,9 @@
|
|||||||
{{ 'CLOTH.RESTART_SIMULATION' | translate }}
|
{{ 'CLOTH.RESTART_SIMULATION' | translate }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2.5">
|
<app-slider-field label="CLOTH.ELONGATION" [min]="0.5" [max]="2.0" [step]="0.1" [(value)]="elongation"/>
|
||||||
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation() }}</span>
|
|
||||||
<mat-slider min="0.5" max="2.0" step="0.1">
|
|
||||||
<input matSliderThumb [(ngModel)]="elongation">
|
|
||||||
</mat-slider>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
<app-babylon-canvas
|
<app-babylon-canvas
|
||||||
[config]="renderConfig"
|
[config]="renderConfig"
|
||||||
(sceneReady)="onSceneReady($event)"
|
(sceneReady)="onSceneReady($event)"
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import {Component, signal} from '@angular/core';
|
import {Component, computed, signal} from '@angular/core';
|
||||||
import {FormsModule} from '@angular/forms';
|
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
import {MatSliderModule} from '@angular/material/slider';
|
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
|
||||||
import {TranslatePipe} from '@ngx-translate/core';
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
import {Engine, Scene, WebGPUEngine} from '@babylonjs/core';
|
||||||
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
import {ClothConfig} from './cloth.model';
|
import {CLOTH_SUBSTEPS, ClothConfig, ClothStats} from './cloth.model';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {SOURCE_PATHS} from '../information/source-paths';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
import {ClothSimulationStrategy} from './strategies/cloth-simulation.strategy';
|
import {ClothSimulationParams, ClothSimulationStrategy} from './strategies/cloth-simulation.strategy';
|
||||||
import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy';
|
import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy';
|
||||||
import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
|
import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-cloth',
|
selector: 'app-cloth',
|
||||||
@@ -23,9 +26,9 @@ import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
|
|||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
BabylonCanvas,
|
BabylonCanvas,
|
||||||
MatButton,
|
MatButton,
|
||||||
MatSliderModule,
|
SliderFieldComponent,
|
||||||
FormsModule,
|
Information,
|
||||||
Information
|
MetricsComponent
|
||||||
],
|
],
|
||||||
templateUrl: './cloth.component.html',
|
templateUrl: './cloth.component.html',
|
||||||
styleUrl: './cloth.component.scss',
|
styleUrl: './cloth.component.scss',
|
||||||
@@ -40,6 +43,19 @@ export class ClothComponent {
|
|||||||
public stiffness: number = 80;
|
public stiffness: number = 80;
|
||||||
public readonly elongation = signal(1.0);
|
public readonly elongation = signal(1.0);
|
||||||
|
|
||||||
|
private readonly fpsCounter = new FpsCounter();
|
||||||
|
private readonly clothConfig = this.getClothConfig();
|
||||||
|
readonly backend = signal('–');
|
||||||
|
readonly stats = signal<ClothStats>({constraints: 0, phases: 0});
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
|
||||||
|
{label: 'METRICS.BACKEND', value: this.backend()},
|
||||||
|
{label: 'METRICS.PARTICLES', value: this.clothConfig.numVertices},
|
||||||
|
{label: 'METRICS.CONSTRAINTS', value: this.stats().constraints},
|
||||||
|
{label: 'METRICS.PHASES', value: this.stats().phases},
|
||||||
|
{label: 'METRICS.SUBSTEPS', value: CLOTH_SUBSTEPS}
|
||||||
|
]);
|
||||||
|
|
||||||
public renderConfig: RenderConfig = {
|
public renderConfig: RenderConfig = {
|
||||||
mode: '3D',
|
mode: '3D',
|
||||||
initialViewSize: 20
|
initialViewSize: 20
|
||||||
@@ -75,7 +91,9 @@ export class ClothComponent {
|
|||||||
],
|
],
|
||||||
disclaimer: 'CLOTH.EXPLANATION.DISCLAIMER',
|
disclaimer: 'CLOTH.EXPLANATION.DISCLAIMER',
|
||||||
disclaimerBottom: '',
|
disclaimerBottom: '',
|
||||||
disclaimerListEntry: ['CLOTH.EXPLANATION.DISCLAIMER_1', 'CLOTH.EXPLANATION.DISCLAIMER_2', 'CLOTH.EXPLANATION.DISCLAIMER_3', 'CLOTH.EXPLANATION.DISCLAIMER_4']
|
disclaimerListEntry: ['CLOTH.EXPLANATION.DISCLAIMER_1', 'CLOTH.EXPLANATION.DISCLAIMER_2', 'CLOTH.EXPLANATION.DISCLAIMER_3', 'CLOTH.EXPLANATION.DISCLAIMER_4'],
|
||||||
|
howItWorks: {intro: 'CLOTH.HOW_IT_WORKS.INTRO', items: ['CLOTH.HOW_IT_WORKS.ITEM_1', 'CLOTH.HOW_IT_WORKS.ITEM_2', 'CLOTH.HOW_IT_WORKS.ITEM_3', 'CLOTH.HOW_IT_WORKS.ITEM_4', 'CLOTH.HOW_IT_WORKS.ITEM_5']},
|
||||||
|
sources: SOURCE_PATHS.CLOTH
|
||||||
};
|
};
|
||||||
|
|
||||||
public onSceneReady(event: SceneEventData): void {
|
public onSceneReady(event: SceneEventData): void {
|
||||||
@@ -107,7 +125,7 @@ export class ClothComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {engine, scene, gpuTier} = this.currentSceneData;
|
const {engine, scene, gpuTier} = this.currentSceneData;
|
||||||
const config = this.getClothConfig();
|
const config = this.clothConfig;
|
||||||
|
|
||||||
if (this.strategy) {
|
if (this.strategy) {
|
||||||
this.strategy.dispose();
|
this.strategy.dispose();
|
||||||
@@ -117,24 +135,27 @@ export class ClothComponent {
|
|||||||
? new ClothGpuStrategy()
|
? new ClothGpuStrategy()
|
||||||
: new ClothCpuStrategy();
|
: new ClothCpuStrategy();
|
||||||
|
|
||||||
this.strategy.init(scene, engine, config);
|
this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
|
||||||
this.startParamUpdateLoop(scene, engine);
|
this.strategy.init(scene, engine, config, () => this.currentParams(engine));
|
||||||
|
this.stats.set(this.strategy.getStats());
|
||||||
|
this.startFrameLoop(scene, engine);
|
||||||
}
|
}
|
||||||
|
|
||||||
private startParamUpdateLoop(scene: any, engine: any): void {
|
private currentParams(engine: WebGPUEngine | Engine): ClothSimulationParams {
|
||||||
|
return {
|
||||||
|
stiffness: this.stiffness,
|
||||||
|
elongation: this.elongation(),
|
||||||
|
isWindActive: this.isWindActive(),
|
||||||
|
simulationTime: this.simulationTime,
|
||||||
|
deltaTime: engine.getDeltaTime() / 1000.0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private startFrameLoop(scene: Scene, engine: WebGPUEngine | Engine): void {
|
||||||
scene.onAfterRenderObservable.clear();
|
scene.onAfterRenderObservable.clear();
|
||||||
scene.onAfterRenderObservable.add(() => {
|
scene.onAfterRenderObservable.add(() => {
|
||||||
|
this.fpsCounter.tick();
|
||||||
this.simulationTime += engine.getDeltaTime() / 1000.0;
|
this.simulationTime += engine.getDeltaTime() / 1000.0;
|
||||||
|
|
||||||
if (this.strategy) {
|
|
||||||
this.strategy.updateParams({
|
|
||||||
stiffness: this.stiffness,
|
|
||||||
elongation: this.elongation(),
|
|
||||||
isWindActive: this.isWindActive(),
|
|
||||||
simulationTime: this.simulationTime,
|
|
||||||
deltaTime: engine.getDeltaTime() / 1000.0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ export interface ClothConfig {
|
|||||||
particleInvMass: number;
|
particleInvMass: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CLOTH_SUBSTEPS = 15;
|
||||||
|
|
||||||
|
export interface ClothStats {
|
||||||
|
constraints: number;
|
||||||
|
phases: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each constraint takes 4 numbers in its phase array. */
|
||||||
|
export function clothStatsOf(constraintPhases: number[][]): ClothStats {
|
||||||
|
return {
|
||||||
|
constraints: constraintPhases.reduce((sum, phase) => sum + phase.length / 4, 0),
|
||||||
|
phases: constraintPhases.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClothData {
|
export interface ClothData {
|
||||||
positions: Float32Array;
|
positions: Float32Array;
|
||||||
prevPositions: Float32Array;
|
prevPositions: Float32Array;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {ClothCpuStrategy} from './cloth-cpu.strategy';
|
||||||
|
import {ClothSimulationParams} from './cloth-simulation.strategy';
|
||||||
|
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
vi.mock('@babylonjs/core', () => {
|
||||||
|
class FakeMaterial {
|
||||||
|
backFaceCulling = true;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ShaderMaterial: FakeMaterial,
|
||||||
|
VertexBuffer: {PositionKind: 'position'},
|
||||||
|
MeshBuilder: {
|
||||||
|
CreateGround: () => ({
|
||||||
|
material: null,
|
||||||
|
updateVerticesData: () => calls.push('mesh')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeScene() {
|
||||||
|
const callbacks: (() => void)[] = [];
|
||||||
|
return {
|
||||||
|
activeCamera: null,
|
||||||
|
removeMesh: vi.fn(),
|
||||||
|
onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)},
|
||||||
|
frame: () => callbacks.forEach((cb) => cb())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ClothCpuStrategy', () => {
|
||||||
|
it('readsParamsAtFrameStart', () => {
|
||||||
|
calls.length = 0;
|
||||||
|
const params: ClothSimulationParams = {stiffness: 50, elongation: 1, isWindActive: false, simulationTime: 0, deltaTime: 0.016};
|
||||||
|
const scene = fakeScene();
|
||||||
|
const strategy = new ClothCpuStrategy();
|
||||||
|
const config = {gridWidth: 4, gridHeight: 4, spacing: 0.05, density: 1, numVertices: 16, particleInvMass: 1};
|
||||||
|
strategy.init(scene as never, {} as never, config, () => {
|
||||||
|
calls.push('read');
|
||||||
|
return params;
|
||||||
|
});
|
||||||
|
|
||||||
|
scene.frame();
|
||||||
|
scene.frame();
|
||||||
|
|
||||||
|
expect(calls).toEqual(['read', 'mesh', 'read', 'mesh']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {ArcRotateCamera, Engine, GroundMesh, MeshBuilder, Scene, ShaderMaterial, VertexBuffer, WebGPUEngine} from '@babylonjs/core';
|
import {ArcRotateCamera, Engine, GroundMesh, MeshBuilder, Scene, ShaderMaterial, VertexBuffer, WebGPUEngine} from '@babylonjs/core';
|
||||||
import {ClothConfig, ClothData} from '../cloth.model';
|
import {CLOTH_SUBSTEPS, ClothConfig, ClothData, ClothStats, clothStatsOf} from '../cloth.model';
|
||||||
import {CLOTH_FRAGMENT_SHADER_GLSL, CLOTH_VERTEX_SHADER_GLSL} from '../cloth-glsl.shader';
|
import {CLOTH_FRAGMENT_SHADER_GLSL, CLOTH_VERTEX_SHADER_GLSL} from '../cloth-glsl.shader';
|
||||||
import {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics';
|
import {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics';
|
||||||
import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy';
|
import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy';
|
||||||
@@ -8,11 +8,13 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
|
|||||||
private clothMesh: GroundMesh | null = null;
|
private clothMesh: GroundMesh | null = null;
|
||||||
private scene: Scene | null = null;
|
private scene: Scene | null = null;
|
||||||
private config: ClothConfig | null = null;
|
private config: ClothConfig | null = null;
|
||||||
|
private readParams: (() => ClothSimulationParams) | null = null;
|
||||||
|
|
||||||
private positions!: Float32Array;
|
private positions!: Float32Array;
|
||||||
private prevPositions!: Float32Array;
|
private prevPositions!: Float32Array;
|
||||||
private velocities!: Float32Array;
|
private velocities!: Float32Array;
|
||||||
private constraintPhases!: Float32Array[];
|
private constraintPhases!: Float32Array[];
|
||||||
|
private stats: ClothStats = {constraints: 0, phases: 0};
|
||||||
|
|
||||||
private physicsParams: ClothPhysicsParams = {
|
private physicsParams: ClothPhysicsParams = {
|
||||||
dt: 0.016,
|
dt: 0.016,
|
||||||
@@ -26,8 +28,9 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
|
|||||||
elongation: 1.0
|
elongation: 1.0
|
||||||
};
|
};
|
||||||
|
|
||||||
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void {
|
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig, readParams: () => ClothSimulationParams): void {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
|
this.readParams = readParams;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.physicsParams.numVertices = config.numVertices;
|
this.physicsParams.numVertices = config.numVertices;
|
||||||
|
|
||||||
@@ -36,12 +39,29 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
|
|||||||
this.prevPositions = clothData.prevPositions;
|
this.prevPositions = clothData.prevPositions;
|
||||||
this.velocities = clothData.velocities;
|
this.velocities = clothData.velocities;
|
||||||
this.constraintPhases = clothData.constraints.map(c => new Float32Array(c));
|
this.constraintPhases = clothData.constraints.map(c => new Float32Array(c));
|
||||||
|
this.stats = clothStatsOf(clothData.constraints);
|
||||||
|
|
||||||
this.setupRenderMesh(scene, config);
|
this.setupRenderMesh(scene, config);
|
||||||
this.startRenderLoop(scene, config);
|
this.startRenderLoop(scene, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateParams(params: ClothSimulationParams): void {
|
getStats(): ClothStats {
|
||||||
|
return this.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMesh(): GroundMesh | null {
|
||||||
|
return this.clothMesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.scene && this.clothMesh) {
|
||||||
|
this.scene.removeMesh(this.clothMesh);
|
||||||
|
}
|
||||||
|
this.clothMesh = null;
|
||||||
|
this.readParams = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyParams(params: ClothSimulationParams): void {
|
||||||
const windX = params.isWindActive ? 5.0 : 0.0;
|
const windX = params.isWindActive ? 5.0 : 0.0;
|
||||||
const windZ = params.isWindActive ? 15.0 : 0.0;
|
const windZ = params.isWindActive ? 15.0 : 0.0;
|
||||||
|
|
||||||
@@ -57,17 +77,6 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
|
|||||||
this.physicsParams.elongation = params.elongation;
|
this.physicsParams.elongation = params.elongation;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMesh(): GroundMesh | null {
|
|
||||||
return this.clothMesh;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
if (this.scene && this.clothMesh) {
|
|
||||||
this.scene.removeMesh(this.clothMesh);
|
|
||||||
}
|
|
||||||
this.clothMesh = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateClothData(config: ClothConfig): ClothData {
|
private generateClothData(config: ClothConfig): ClothData {
|
||||||
const positionsData = new Float32Array(config.numVertices * 4);
|
const positionsData = new Float32Array(config.numVertices * 4);
|
||||||
const prevPositionsData = new Float32Array(config.numVertices * 4);
|
const prevPositionsData = new Float32Array(config.numVertices * 4);
|
||||||
@@ -191,17 +200,16 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startRenderLoop(scene: Scene, config: ClothConfig): void {
|
private startRenderLoop(scene: Scene, config: ClothConfig): void {
|
||||||
const substeps = 15;
|
|
||||||
|
|
||||||
scene.onBeforeRenderObservable.clear();
|
scene.onBeforeRenderObservable.clear();
|
||||||
scene.onBeforeRenderObservable.add(() => {
|
scene.onBeforeRenderObservable.add(() => {
|
||||||
if (!this.clothMesh) {
|
if (!this.clothMesh || !this.readParams) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.applyParams(this.readParams());
|
||||||
integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams);
|
integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams);
|
||||||
|
|
||||||
for (let i = 0; i < substeps; i++) {
|
for (let i = 0; i < CLOTH_SUBSTEPS; i++) {
|
||||||
for (const phase of this.constraintPhases) {
|
for (const phase of this.constraintPhases) {
|
||||||
solveConstraints(this.positions, phase, this.physicsParams);
|
solveConstraints(this.positions, phase, this.physicsParams);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {ArcRotateCamera, ComputeShader, Engine, GroundMesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
|
import {ArcRotateCamera, ComputeShader, Engine, GroundMesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
|
||||||
import {ClothConfig, ClothData} from '../cloth.model';
|
import {CLOTH_SUBSTEPS, ClothConfig, ClothData, ClothStats, clothStatsOf} from '../cloth.model';
|
||||||
import {
|
import {
|
||||||
CLOTH_FRAGMENT_SHADER_WGSL,
|
CLOTH_FRAGMENT_SHADER_WGSL,
|
||||||
CLOTH_INTEGRATE_COMPUTE_WGSL,
|
CLOTH_INTEGRATE_COMPUTE_WGSL,
|
||||||
@@ -32,13 +32,17 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
|
|||||||
private dispatchXConstraints: number[] = [];
|
private dispatchXConstraints: number[] = [];
|
||||||
private dispatchXVertices = 0;
|
private dispatchXVertices = 0;
|
||||||
private numVertices = 0;
|
private numVertices = 0;
|
||||||
|
private readParams: (() => ClothSimulationParams) | null = null;
|
||||||
|
private stats: ClothStats = {constraints: 0, phases: 0};
|
||||||
|
|
||||||
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void {
|
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig, readParams: () => ClothSimulationParams): void {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
|
this.readParams = readParams;
|
||||||
this.numVertices = config.numVertices;
|
this.numVertices = config.numVertices;
|
||||||
const gpuEngine = engine as WebGPUEngine;
|
const gpuEngine = engine as WebGPUEngine;
|
||||||
|
|
||||||
const clothData = this.generateClothData(config);
|
const clothData = this.generateClothData(config);
|
||||||
|
this.stats = clothStatsOf(clothData.constraints);
|
||||||
this.buffers = this.createStorageBuffers(gpuEngine, clothData);
|
this.buffers = this.createStorageBuffers(gpuEngine, clothData);
|
||||||
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers);
|
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers);
|
||||||
this.setupRenderMesh(scene, config, this.buffers.positions);
|
this.setupRenderMesh(scene, config, this.buffers.positions);
|
||||||
@@ -46,7 +50,25 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
|
|||||||
this.startRenderLoop(scene);
|
this.startRenderLoop(scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateParams(params: ClothSimulationParams): void {
|
getStats(): ClothStats {
|
||||||
|
return this.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMesh(): GroundMesh | null {
|
||||||
|
return this.clothMesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.scene && this.clothMesh) {
|
||||||
|
this.scene.removeMesh(this.clothMesh);
|
||||||
|
}
|
||||||
|
this.clothMesh = null;
|
||||||
|
this.buffers = null;
|
||||||
|
this.pipelines = null;
|
||||||
|
this.readParams = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyParams(params: ClothSimulationParams): void {
|
||||||
if (!this.buffers) {
|
if (!this.buffers) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -70,19 +92,6 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
|
|||||||
this.paramsData[8] = params.elongation;
|
this.paramsData[8] = params.elongation;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMesh(): GroundMesh | null {
|
|
||||||
return this.clothMesh;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
if (this.scene && this.clothMesh) {
|
|
||||||
this.scene.removeMesh(this.clothMesh);
|
|
||||||
}
|
|
||||||
this.clothMesh = null;
|
|
||||||
this.buffers = null;
|
|
||||||
this.pipelines = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateClothData(config: ClothConfig): ClothData {
|
private generateClothData(config: ClothConfig): ClothData {
|
||||||
const positionsData = new Float32Array(config.numVertices * 4);
|
const positionsData = new Float32Array(config.numVertices * 4);
|
||||||
const prevPositionsData = new Float32Array(config.numVertices * 4);
|
const prevPositionsData = new Float32Array(config.numVertices * 4);
|
||||||
@@ -251,19 +260,18 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startRenderLoop(scene: Scene): void {
|
private startRenderLoop(scene: Scene): void {
|
||||||
const substeps = 15;
|
|
||||||
|
|
||||||
scene.onBeforeRenderObservable.clear();
|
scene.onBeforeRenderObservable.clear();
|
||||||
scene.onBeforeRenderObservable.add(() => {
|
scene.onBeforeRenderObservable.add(() => {
|
||||||
if (!this.buffers || !this.pipelines) {
|
if (!this.buffers || !this.pipelines || !this.readParams) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.applyParams(this.readParams());
|
||||||
this.buffers.params.update(this.paramsData);
|
this.buffers.params.update(this.paramsData);
|
||||||
|
|
||||||
this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1);
|
this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1);
|
||||||
|
|
||||||
for (let i = 0; i < substeps; i++) {
|
for (let i = 0; i < CLOTH_SUBSTEPS; i++) {
|
||||||
for (let phase = 0; phase < this.pipelines.solvers.length; phase++) {
|
for (let phase = 0; phase < this.pipelines.solvers.length; phase++) {
|
||||||
this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1);
|
this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core';
|
import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core';
|
||||||
import {ClothConfig} from '../cloth.model';
|
import {ClothConfig, ClothStats} from '../cloth.model';
|
||||||
|
|
||||||
export interface ClothSimulationParams {
|
export interface ClothSimulationParams {
|
||||||
stiffness: number;
|
stiffness: number;
|
||||||
@@ -10,8 +10,8 @@ export interface ClothSimulationParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ClothSimulationStrategy {
|
export interface ClothSimulationStrategy {
|
||||||
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void;
|
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig, readParams: () => ClothSimulationParams): void;
|
||||||
updateParams(params: ClothSimulationParams): void;
|
|
||||||
getMesh(): GroundMesh | null;
|
getMesh(): GroundMesh | null;
|
||||||
|
getStats(): ClothStats;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
|
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime() }} ms</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
<div class="flex gap-3 items-center flex-wrap">
|
<div class="flex gap-3 items-center flex-wrap">
|
||||||
<mat-form-field appearance="outline" class="w-[150px]">
|
<mat-form-field appearance="outline" class="w-[150px]">
|
||||||
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
|
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||||
import {provideZonelessChangeDetection} from '@angular/core';
|
import {provideZonelessChangeDetection} from '@angular/core';
|
||||||
import {provideNoopAnimations} from '@angular/platform-browser/animations';
|
import {provideNoopAnimations} from '@angular/platform-browser/animations';
|
||||||
|
import {provideRouter} from '@angular/router';
|
||||||
import {provideTranslateService} from '@ngx-translate/core';
|
import {provideTranslateService} from '@ngx-translate/core';
|
||||||
import {ConwayGolComponent} from './conway-gol.component';
|
import {ConwayGolComponent} from './conway-gol.component';
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ describe('ConwayGolComponent', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
provideZonelessChangeDetection(),
|
provideZonelessChangeDetection(),
|
||||||
provideNoopAnimations(),
|
provideNoopAnimations(),
|
||||||
|
provideRouter([]),
|
||||||
provideTranslateService({lang: 'en', fallbackLang: 'en'}),
|
provideTranslateService({lang: 'en', fallbackLang: 'en'}),
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
@@ -65,4 +67,16 @@ describe('ConwayGolComponent', () => {
|
|||||||
expect(component['gridCols']()).toBeNull();
|
expect(component['gridCols']()).toBeNull();
|
||||||
expect(component.readGrid[0].length).toBe(20);
|
expect(component.readGrid[0].length).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts generations while running and restarts at 0 on generate', () => {
|
||||||
|
const run = component.startGame();
|
||||||
|
component.pauseGame();
|
||||||
|
|
||||||
|
expect(component.generation()).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
component.generate(component.currentScenario);
|
||||||
|
|
||||||
|
expect(component.generation()).toBe(0);
|
||||||
|
return run;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core';
|
import {AfterViewInit, Component, computed, inject, signal, ViewChild} from '@angular/core';
|
||||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
|
||||||
import {TranslatePipe} from "@ngx-translate/core";
|
import {TranslatePipe} from "@ngx-translate/core";
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {RouterConstants} from '../../../constants/RouterConstants';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {SOURCE_PATHS} from '../information/source-paths';
|
||||||
import {MatButton} from '@angular/material/button';
|
import {MatButton} from '@angular/material/button';
|
||||||
import {MatIcon} from '@angular/material/icon';
|
import {MatIcon} from '@angular/material/icon';
|
||||||
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
|
||||||
@@ -11,6 +13,8 @@ import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
|||||||
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, MAX_GRID_PX, MAX_GRID_SIZE, MAX_TIME_PER_GENERATION, MIN_GRID_SIZE, MIN_TIME_PER_GENERATION, Node, Scenario} from './conway-gol.models';
|
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, MAX_GRID_PX, MAX_GRID_SIZE, MAX_TIME_PER_GENERATION, MIN_GRID_SIZE, MIN_TIME_PER_GENERATION, Node, Scenario} from './conway-gol.models';
|
||||||
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
import {GenericGridComponent, GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
import {ConwayGolService} from './service/conway-gol.service';
|
import {ConwayGolService} from './service/conway-gol.service';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-conway-gol',
|
selector: 'app-conway-gol',
|
||||||
@@ -28,7 +32,8 @@ import {ConwayGolService} from './service/conway-gol.service';
|
|||||||
MatLabel,
|
MatLabel,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
GenericGridComponent
|
GenericGridComponent,
|
||||||
|
MetricsComponent
|
||||||
],
|
],
|
||||||
templateUrl: './conway-gol.component.html',
|
templateUrl: './conway-gol.component.html',
|
||||||
})
|
})
|
||||||
@@ -42,11 +47,20 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
name: '',
|
name: '',
|
||||||
description: 'GOL.EXPLANATION.EXPLANATION',
|
description: 'GOL.EXPLANATION.EXPLANATION',
|
||||||
link: UrlConstants.CONWAYS_WIKI
|
link: UrlConstants.CONWAYS_WIKI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GOL.EXPLANATION.LENIA_TITLE',
|
||||||
|
description: 'GOL.EXPLANATION.LENIA',
|
||||||
|
routerLink: RouterConstants.LENIA.LINK,
|
||||||
|
linkLabel: 'Lenia',
|
||||||
|
translateName: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
disclaimer: 'GOL.EXPLANATION.DISCLAIMER',
|
disclaimer: 'GOL.EXPLANATION.DISCLAIMER',
|
||||||
disclaimerBottom: '',
|
disclaimerBottom: '',
|
||||||
disclaimerListEntry: ['GOL.EXPLANATION.DISCLAIMER_1', 'GOL.EXPLANATION.DISCLAIMER_2', 'GOL.EXPLANATION.DISCLAIMER_3', 'GOL.EXPLANATION.DISCLAIMER_4']
|
disclaimerListEntry: ['GOL.EXPLANATION.DISCLAIMER_1', 'GOL.EXPLANATION.DISCLAIMER_2', 'GOL.EXPLANATION.DISCLAIMER_3', 'GOL.EXPLANATION.DISCLAIMER_4'],
|
||||||
|
howItWorks: {intro: 'GOL.HOW_IT_WORKS.INTRO', items: ['GOL.HOW_IT_WORKS.ITEM_1', 'GOL.HOW_IT_WORKS.ITEM_2', 'GOL.HOW_IT_WORKS.ITEM_3', 'GOL.HOW_IT_WORKS.ITEM_4']},
|
||||||
|
sources: SOURCE_PATHS.GOL
|
||||||
};
|
};
|
||||||
|
|
||||||
protected readonly gridCols = signal(DEFAULT_GRID_COLS);
|
protected readonly gridCols = signal(DEFAULT_GRID_COLS);
|
||||||
@@ -59,6 +73,13 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
readGrid: Node[][] = [];
|
readGrid: Node[][] = [];
|
||||||
writeGrid: Node[][] = [];
|
writeGrid: Node[][] = [];
|
||||||
readonly executionTime = signal(0);
|
readonly executionTime = signal(0);
|
||||||
|
readonly generation = signal(0);
|
||||||
|
private readonly generationRate = new FpsCounter();
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.GENERATION', value: this.generation()},
|
||||||
|
{label: 'METRICS.GENERATIONS_PER_SECOND', value: this.generationRate.fps()},
|
||||||
|
{label: 'METRICS.STEP_TIME', value: this.executionTime(), unit: 'ms'}
|
||||||
|
]);
|
||||||
currentScenario: Scenario = Scenario.SIMPLE;
|
currentScenario: Scenario = Scenario.SIMPLE;
|
||||||
readonly gameStarted = signal(false);
|
readonly gameStarted = signal(false);
|
||||||
|
|
||||||
@@ -140,6 +161,8 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
|
|
||||||
initializeConwayGrid = (grid: Node[][]): void => {
|
initializeConwayGrid = (grid: Node[][]): void => {
|
||||||
this.gameStarted.set(false);
|
this.gameStarted.set(false);
|
||||||
|
this.generation.set(0);
|
||||||
|
this.generationRate.reset();
|
||||||
this.readGrid = grid;
|
this.readGrid = grid;
|
||||||
this.golService.applyScenario(grid, this.currentScenario);
|
this.golService.applyScenario(grid, this.currentScenario);
|
||||||
this.writeGrid = structuredClone(this.readGrid);
|
this.writeGrid = structuredClone(this.readGrid);
|
||||||
@@ -158,6 +181,8 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid);
|
const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid);
|
||||||
|
|
||||||
this.swapGrids();
|
this.swapGrids();
|
||||||
|
this.generation.update(count => count + 1);
|
||||||
|
this.generationRate.tick();
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
const elapsed = Number.parseFloat((endTime - startTime).toFixed(4));
|
const elapsed = Number.parseFloat((endTime - startTime).toFixed(4));
|
||||||
this.executionTime.set(elapsed);
|
this.executionTime.set(elapsed);
|
||||||
@@ -168,6 +193,7 @@ export class ConwayGolComponent implements AfterViewInit {
|
|||||||
await this.delay(delta);
|
await this.delay(delta);
|
||||||
}
|
}
|
||||||
this.executionTime.set(0);
|
this.executionTime.set(0);
|
||||||
|
this.generationRate.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private swapGrids() {
|
private swapGrids() {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type {FluidParams} from './fluid-simulation';
|
||||||
|
import {DEFAULT_SETTINGS, MAX_PRESSURE_ITERATIONS, Splat} from './fluid.model';
|
||||||
|
|
||||||
|
const renderCalls: string[] = [];
|
||||||
|
const wrapperNames = [
|
||||||
|
'fluidClear', 'fluidSplat', 'fluidCurl', 'fluidVorticity', 'fluidDivergence', 'fluidPressure', 'fluidGradientSubtract', 'fluidAdvect'
|
||||||
|
];
|
||||||
|
const ready: Record<string, boolean> = {};
|
||||||
|
let disposedTargets = 0;
|
||||||
|
let disposedWrappers = 0;
|
||||||
|
|
||||||
|
vi.mock('@babylonjs/core', () => {
|
||||||
|
class FakeWrapper {
|
||||||
|
readonly name: string;
|
||||||
|
readonly onApplyObservable = {add: vi.fn()};
|
||||||
|
readonly effect = {isReady: () => ready[this.name]};
|
||||||
|
constructor(options: {name: string}) {
|
||||||
|
this.name = options.name;
|
||||||
|
}
|
||||||
|
dispose(): void { disposedWrappers++; }
|
||||||
|
}
|
||||||
|
class FakeRenderer {
|
||||||
|
render(wrapper: FakeWrapper): void {
|
||||||
|
renderCalls.push(wrapper.name);
|
||||||
|
}
|
||||||
|
dispose(): void { /* nothing to free */ }
|
||||||
|
}
|
||||||
|
class FakeTarget {
|
||||||
|
wrapU = 0;
|
||||||
|
wrapV = 0;
|
||||||
|
constructor(readonly name: string) {}
|
||||||
|
dispose(): void { disposedTargets++; }
|
||||||
|
}
|
||||||
|
class FakeMaterial {
|
||||||
|
backFaceCulling = true;
|
||||||
|
disableDepthWrite = false;
|
||||||
|
setTexture(): void { /* display texture is not under test */ }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
Constants: {TEXTURETYPE_FLOAT: 1, TEXTURETYPE_HALF_FLOAT: 2, TEXTUREFORMAT_RGBA: 5},
|
||||||
|
Texture: {NEAREST_SAMPLINGMODE: 1, WRAP_ADDRESSMODE: 1},
|
||||||
|
EffectRenderer: FakeRenderer,
|
||||||
|
EffectWrapper: FakeWrapper,
|
||||||
|
RenderTargetTexture: FakeTarget,
|
||||||
|
ShaderMaterial: FakeMaterial,
|
||||||
|
MeshBuilder: {CreatePlane: () => ({alwaysSelectAsActiveMesh: false, material: null, lookAt: vi.fn(), dispose: vi.fn()})}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeScene() {
|
||||||
|
const callbacks: (() => void)[] = [];
|
||||||
|
return {
|
||||||
|
activeCamera: null,
|
||||||
|
onBeforeRenderObservable: {clear: () => callbacks.splice(0), add: (cb: () => void) => callbacks.push(cb)},
|
||||||
|
frame: () => callbacks.forEach((cb) => cb())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const splat: Splat = {u: 0.5, v: 0.5, dx: 1, dy: 0, r: 0.1, g: 0.1, b: 0.1};
|
||||||
|
|
||||||
|
function params(pressureIterations: number, splats: Splat[] = []): FluidParams {
|
||||||
|
return {settings: {...DEFAULT_SETTINGS, pressureIterations}, splats};
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpu-state.ts is shared between specs and keeps the @babylonjs/core of whichever spec loaded it first (the real
|
||||||
|
// one, or another spec's mock). A fresh module graph per test makes it use this spec's mock.
|
||||||
|
async function start(readParams: () => FluidParams) {
|
||||||
|
vi.resetModules();
|
||||||
|
const {FluidSimulation} = await import('./fluid-simulation');
|
||||||
|
renderCalls.length = 0;
|
||||||
|
disposedTargets = 0;
|
||||||
|
disposedWrappers = 0;
|
||||||
|
wrapperNames.forEach((name) => ready[name] = true);
|
||||||
|
const scene = fakeScene();
|
||||||
|
const simulation = new FluidSimulation();
|
||||||
|
simulation.init(scene as never, {} as never, 'float', readParams);
|
||||||
|
return {scene, simulation};
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(name: string): number {
|
||||||
|
return renderCalls.filter((call) => call === name).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FluidSimulation', () => {
|
||||||
|
it('drawsNothingUntilAllShadersReady', async () => {
|
||||||
|
const readParams = vi.fn(() => params(1, [splat]));
|
||||||
|
const {scene} = await start(readParams);
|
||||||
|
ready['fluidAdvect'] = false;
|
||||||
|
|
||||||
|
scene.frame();
|
||||||
|
expect(renderCalls).toHaveLength(0);
|
||||||
|
expect(readParams).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
ready['fluidAdvect'] = true;
|
||||||
|
scene.frame();
|
||||||
|
expect(renderCalls[0]).toBe('fluidClear');
|
||||||
|
expect(renderCalls).toContain('fluidSplat');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readsParamsAtFrameStart', async () => {
|
||||||
|
let iterations = 5;
|
||||||
|
const {scene} = await start(() => params(iterations));
|
||||||
|
scene.frame();
|
||||||
|
expect(count('fluidPressure')).toBe(5);
|
||||||
|
|
||||||
|
iterations = 12;
|
||||||
|
renderCalls.length = 0;
|
||||||
|
scene.frame();
|
||||||
|
expect(count('fluidPressure')).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clampsPressureIterations', async () => {
|
||||||
|
const {scene} = await start(() => params(1000));
|
||||||
|
scene.frame();
|
||||||
|
expect(count('fluidPressure')).toBe(MAX_PRESSURE_ITERATIONS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passOrder', async () => {
|
||||||
|
const {scene} = await start(() => params(2, [splat]));
|
||||||
|
scene.frame();
|
||||||
|
renderCalls.length = 0;
|
||||||
|
scene.frame();
|
||||||
|
|
||||||
|
expect(renderCalls).toEqual([
|
||||||
|
'fluidSplat', 'fluidSplat', 'fluidCurl', 'fluidVorticity', 'fluidDivergence', 'fluidPressure', 'fluidPressure',
|
||||||
|
'fluidGradientSubtract', 'fluidAdvect', 'fluidAdvect'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetClearsBeforeSplats', async () => {
|
||||||
|
const {scene, simulation} = await start(() => params(1, [splat]));
|
||||||
|
scene.frame();
|
||||||
|
renderCalls.length = 0;
|
||||||
|
scene.frame();
|
||||||
|
expect(count('fluidClear')).toBe(0);
|
||||||
|
|
||||||
|
simulation.reset();
|
||||||
|
renderCalls.length = 0;
|
||||||
|
scene.frame();
|
||||||
|
expect(renderCalls.slice(0, 4)).toEqual(['fluidClear', 'fluidClear', 'fluidClear', 'fluidSplat']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disposeFreesAllTargets', async () => {
|
||||||
|
const {simulation} = await start(() => params(1));
|
||||||
|
simulation.dispose();
|
||||||
|
expect(disposedTargets).toBe(8);
|
||||||
|
expect(disposedWrappers).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import {
|
||||||
|
Effect, Engine, EffectRenderer, EffectWrapper, Mesh, MeshBuilder, RenderTargetTexture, Scene, ShaderMaterial, WebGPUEngine
|
||||||
|
} from '@babylonjs/core';
|
||||||
|
import {createStateTarget, DISPLAY_VERTEX_GLSL, StateTextureType} from '../../../shared/gpu-state';
|
||||||
|
import {
|
||||||
|
clampIterations, CURL_STRENGTH, DYE_SIZE, FluidSettings, SIM_DT, Splat, VELOCITY_SIZE, WORLD_SIZE
|
||||||
|
} from './fluid.model';
|
||||||
|
import {
|
||||||
|
ADVECT_FRAGMENT_GLSL, CLEAR_FRAGMENT_GLSL, CURL_FRAGMENT_GLSL, DISPLAY_FRAGMENT_GLSL, DIVERGENCE_FRAGMENT_GLSL,
|
||||||
|
GRADIENT_SUBTRACT_FRAGMENT_GLSL, PRESSURE_FRAGMENT_GLSL, SPLAT_FRAGMENT_GLSL, VORTICITY_FRAGMENT_GLSL
|
||||||
|
} from './fluid.shader';
|
||||||
|
|
||||||
|
export interface FluidParams {
|
||||||
|
settings: FluidSettings;
|
||||||
|
splats: Splat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldPair {
|
||||||
|
read: RenderTargetTexture;
|
||||||
|
write: RenderTargetTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplatJob {
|
||||||
|
source: RenderTargetTexture;
|
||||||
|
u: number;
|
||||||
|
v: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdvectJob {
|
||||||
|
source: RenderTargetTexture;
|
||||||
|
size: number;
|
||||||
|
dissipation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Passes {
|
||||||
|
clear: EffectWrapper;
|
||||||
|
splat: EffectWrapper;
|
||||||
|
curl: EffectWrapper;
|
||||||
|
vorticity: EffectWrapper;
|
||||||
|
divergence: EffectWrapper;
|
||||||
|
pressure: EffectWrapper;
|
||||||
|
gradientSubtract: EffectWrapper;
|
||||||
|
advect: EffectWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Fields {
|
||||||
|
velocity: FieldPair;
|
||||||
|
pressure: FieldPair;
|
||||||
|
dye: FieldPair;
|
||||||
|
divergence: RenderTargetTexture;
|
||||||
|
curl: RenderTargetTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function swap(pair: FieldPair): void {
|
||||||
|
[pair.read, pair.write] = [pair.write, pair.read];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPair(name: string, scene: Scene, size: number, type: StateTextureType): FieldPair {
|
||||||
|
return {
|
||||||
|
read: createStateTarget(`${name}A`, scene, size, type),
|
||||||
|
write: createStateTarget(`${name}B`, scene, size, type)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable fluids on float render targets: splat, vorticity, pressure projection, advection.
|
||||||
|
* The shaders clamp every coordinate to the field, so the wrap mode of the targets does not matter.
|
||||||
|
*/
|
||||||
|
export class FluidSimulation {
|
||||||
|
private scene: Scene | null = null;
|
||||||
|
private renderer: EffectRenderer | null = null;
|
||||||
|
private passes: Passes | null = null;
|
||||||
|
private fields: Fields | null = null;
|
||||||
|
private material: ShaderMaterial | null = null;
|
||||||
|
private plane: Mesh | null = null;
|
||||||
|
private readParams: (() => FluidParams) | null = null;
|
||||||
|
private splatJob: SplatJob | null = null;
|
||||||
|
private advectJob: AdvectJob | null = null;
|
||||||
|
private needsClear = true;
|
||||||
|
|
||||||
|
init(scene: Scene, engine: WebGPUEngine | Engine, textureType: StateTextureType, readParams: () => FluidParams): void {
|
||||||
|
this.scene = scene;
|
||||||
|
this.readParams = readParams;
|
||||||
|
this.renderer = new EffectRenderer(engine);
|
||||||
|
this.fields = {
|
||||||
|
velocity: createPair('fluidVelocity', scene, VELOCITY_SIZE, textureType),
|
||||||
|
pressure: createPair('fluidPressure', scene, VELOCITY_SIZE, textureType),
|
||||||
|
dye: createPair('fluidDye', scene, DYE_SIZE, textureType),
|
||||||
|
divergence: createStateTarget('fluidDivergence', scene, VELOCITY_SIZE, textureType),
|
||||||
|
curl: createStateTarget('fluidCurl', scene, VELOCITY_SIZE, textureType)
|
||||||
|
};
|
||||||
|
this.passes = this.createPasses(engine);
|
||||||
|
this.needsClear = true;
|
||||||
|
|
||||||
|
this.setupDisplay(scene);
|
||||||
|
scene.onBeforeRenderObservable.clear();
|
||||||
|
scene.onBeforeRenderObservable.add(() => this.frame());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next frame clears velocity, pressure and dye. */
|
||||||
|
reset(): void {
|
||||||
|
this.needsClear = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.scene?.onBeforeRenderObservable.clear();
|
||||||
|
if (this.passes) {
|
||||||
|
Object.values(this.passes).forEach((pass) => pass.dispose());
|
||||||
|
}
|
||||||
|
this.renderer?.dispose();
|
||||||
|
if (this.fields) {
|
||||||
|
const {velocity, pressure, dye, divergence, curl} = this.fields;
|
||||||
|
[velocity.read, velocity.write, pressure.read, pressure.write, dye.read, dye.write, divergence, curl]
|
||||||
|
.forEach((target) => target.dispose());
|
||||||
|
}
|
||||||
|
this.plane?.dispose(false, true);
|
||||||
|
this.passes = null;
|
||||||
|
this.fields = null;
|
||||||
|
this.renderer = null;
|
||||||
|
this.material = null;
|
||||||
|
this.plane = null;
|
||||||
|
this.readParams = null;
|
||||||
|
this.splatJob = null;
|
||||||
|
this.advectJob = null;
|
||||||
|
this.scene = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPasses(engine: WebGPUEngine | Engine): Passes {
|
||||||
|
const create = (
|
||||||
|
name: string, fragmentShader: string, uniformNames: string[], samplerNames: string[], apply?: (effect: Effect) => void
|
||||||
|
) => {
|
||||||
|
const wrapper = new EffectWrapper({engine, name, fragmentShader, uniformNames, samplerNames});
|
||||||
|
if (apply) {
|
||||||
|
wrapper.onApplyObservable.add(() => apply(wrapper.effect));
|
||||||
|
}
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
clear: create('fluidClear', CLEAR_FRAGMENT_GLSL, [], []),
|
||||||
|
splat: create('fluidSplat', SPLAT_FRAGMENT_GLSL, ['point', 'value', 'radius'], ['target'], (effect) => {
|
||||||
|
const job = this.splatJob;
|
||||||
|
if (!job) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
effect.setTexture('target', job.source);
|
||||||
|
effect.setFloat2('point', job.u, job.v);
|
||||||
|
effect.setFloat3('value', job.x, job.y, job.z);
|
||||||
|
effect.setFloat('radius', job.radius);
|
||||||
|
}),
|
||||||
|
curl: create('fluidCurl', CURL_FRAGMENT_GLSL, [], ['velocity'], (effect) => {
|
||||||
|
effect.setTexture('velocity', this.fields?.velocity.read ?? null);
|
||||||
|
}),
|
||||||
|
vorticity: create('fluidVorticity', VORTICITY_FRAGMENT_GLSL, ['curlStrength', 'dt'], ['velocity', 'curl'], (effect) => {
|
||||||
|
effect.setTexture('velocity', this.fields?.velocity.read ?? null);
|
||||||
|
effect.setTexture('curl', this.fields?.curl ?? null);
|
||||||
|
effect.setFloat('curlStrength', CURL_STRENGTH);
|
||||||
|
effect.setFloat('dt', SIM_DT);
|
||||||
|
}),
|
||||||
|
divergence: create('fluidDivergence', DIVERGENCE_FRAGMENT_GLSL, [], ['velocity'], (effect) => {
|
||||||
|
effect.setTexture('velocity', this.fields?.velocity.read ?? null);
|
||||||
|
}),
|
||||||
|
pressure: create('fluidPressure', PRESSURE_FRAGMENT_GLSL, [], ['pressure', 'divergence'], (effect) => {
|
||||||
|
effect.setTexture('pressure', this.fields?.pressure.read ?? null);
|
||||||
|
effect.setTexture('divergence', this.fields?.divergence ?? null);
|
||||||
|
}),
|
||||||
|
gradientSubtract: create('fluidGradientSubtract', GRADIENT_SUBTRACT_FRAGMENT_GLSL, [], ['pressure', 'velocity'], (effect) => {
|
||||||
|
effect.setTexture('pressure', this.fields?.pressure.read ?? null);
|
||||||
|
effect.setTexture('velocity', this.fields?.velocity.read ?? null);
|
||||||
|
}),
|
||||||
|
advect: create('fluidAdvect', ADVECT_FRAGMENT_GLSL, ['sourceSize', 'dt', 'dissipation'], ['velocity', 'source'], (effect) => {
|
||||||
|
const job = this.advectJob;
|
||||||
|
if (!job) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
effect.setTexture('velocity', this.fields?.velocity.read ?? null);
|
||||||
|
effect.setTexture('source', job.source);
|
||||||
|
effect.setFloat('sourceSize', job.size);
|
||||||
|
effect.setFloat('dt', SIM_DT);
|
||||||
|
effect.setFloat('dissipation', job.dissipation);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupDisplay(scene: Scene): void {
|
||||||
|
const material = new ShaderMaterial('fluidMat', scene, {
|
||||||
|
vertexSource: DISPLAY_VERTEX_GLSL,
|
||||||
|
fragmentSource: DISPLAY_FRAGMENT_GLSL
|
||||||
|
}, {
|
||||||
|
attributes: ['position', 'uv'],
|
||||||
|
uniforms: ['worldViewProjection'],
|
||||||
|
samplers: ['dye']
|
||||||
|
});
|
||||||
|
material.setTexture('dye', this.fields!.dye.read);
|
||||||
|
material.backFaceCulling = false;
|
||||||
|
material.disableDepthWrite = true;
|
||||||
|
|
||||||
|
const plane = MeshBuilder.CreatePlane('fluid', {size: WORLD_SIZE}, scene);
|
||||||
|
if (scene.activeCamera) {
|
||||||
|
plane.lookAt(scene.activeCamera.position);
|
||||||
|
}
|
||||||
|
plane.alwaysSelectAsActiveMesh = true;
|
||||||
|
plane.material = material;
|
||||||
|
this.material = material;
|
||||||
|
this.plane = plane;
|
||||||
|
}
|
||||||
|
|
||||||
|
private allReady(passes: Passes): boolean {
|
||||||
|
return Object.values(passes).every((pass) => pass.effect.isReady());
|
||||||
|
}
|
||||||
|
|
||||||
|
private frame(): void {
|
||||||
|
const {renderer, passes, fields, readParams} = this;
|
||||||
|
if (!renderer || !passes || !fields || !readParams) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Before the params are read and the queue is drained, so nothing is lost while shaders compile.
|
||||||
|
if (!this.allReady(passes)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const {settings, splats} = readParams();
|
||||||
|
|
||||||
|
if (this.needsClear) {
|
||||||
|
renderer.render(passes.clear, fields.velocity.read);
|
||||||
|
renderer.render(passes.clear, fields.pressure.read);
|
||||||
|
renderer.render(passes.clear, fields.dye.read);
|
||||||
|
this.needsClear = false;
|
||||||
|
}
|
||||||
|
for (const splat of splats) {
|
||||||
|
this.addSplat(fields.velocity, splat, splat.dx, splat.dy, 0, settings.splatRadius);
|
||||||
|
this.addSplat(fields.dye, splat, splat.r, splat.g, splat.b, settings.splatRadius);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.render(passes.curl, fields.curl);
|
||||||
|
renderer.render(passes.vorticity, fields.velocity.write);
|
||||||
|
swap(fields.velocity);
|
||||||
|
|
||||||
|
renderer.render(passes.divergence, fields.divergence);
|
||||||
|
const iterations = clampIterations(settings.pressureIterations);
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
renderer.render(passes.pressure, fields.pressure.write);
|
||||||
|
swap(fields.pressure);
|
||||||
|
}
|
||||||
|
renderer.render(passes.gradientSubtract, fields.velocity.write);
|
||||||
|
swap(fields.velocity);
|
||||||
|
|
||||||
|
this.advect(fields.dye, DYE_SIZE, settings.dyeFade);
|
||||||
|
this.advect(fields.velocity, VELOCITY_SIZE, settings.velocityDissipation);
|
||||||
|
this.material?.setTexture('dye', fields.dye.read);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addSplat(pair: FieldPair, splat: Splat, x: number, y: number, z: number, radius: number): void {
|
||||||
|
this.splatJob = {source: pair.read, u: splat.u, v: splat.v, x, y, z, radius};
|
||||||
|
this.renderer?.render(this.passes!.splat, pair.write);
|
||||||
|
swap(pair);
|
||||||
|
}
|
||||||
|
|
||||||
|
private advect(pair: FieldPair, size: number, dissipation: number): void {
|
||||||
|
this.advectJob = {source: pair.read, size, dissipation};
|
||||||
|
this.renderer?.render(this.passes!.advect, pair.write);
|
||||||
|
swap(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'FLUID.TITLE' | translate }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
|
<div class="flex flex-col mb-4">
|
||||||
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
|
<button mat-raised-button color="accent" (click)="reset()">
|
||||||
|
{{ 'FLUID.RESET' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@for (slider of sliders; track slider.key) {
|
||||||
|
<app-slider-field [label]="slider.label" [min]="limits[slider.key].min" [max]="limits[slider.key].max"
|
||||||
|
[step]="limits[slider.key].step" [(value)]="slider.model"/>
|
||||||
|
}
|
||||||
|
<p class="text-[0.9em] opacity-80">{{ 'FLUID.DRAG_HINT' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
@if (unsupported()) {
|
||||||
|
<p class="text-[0.9em] text-red-500">{{ 'FLUID.NO_FLOAT_TEXTURES' | translate }}</p>
|
||||||
|
}
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
|
<app-babylon-canvas
|
||||||
|
[config]="renderConfig"
|
||||||
|
(sceneReady)="onSceneReady($event)"
|
||||||
|
(sceneResized)="onSceneResized($event)"
|
||||||
|
/>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import {Component, computed, OnDestroy, signal, WritableSignal} from '@angular/core';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
import {PointerEventTypes, Scene} from '@babylonjs/core';
|
||||||
|
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {SOURCE_PATHS} from '../information/source-paths';
|
||||||
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
|
import {pointerToUv, stateTextureTypeFor} from '../../../shared/gpu-state';
|
||||||
|
import {
|
||||||
|
clampIterations,
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
dyeColor,
|
||||||
|
DYE_SIZE,
|
||||||
|
FluidSettings,
|
||||||
|
HUE_PERIOD_SEC,
|
||||||
|
pointerSplat,
|
||||||
|
randomSplat,
|
||||||
|
SETTINGS_LIMITS,
|
||||||
|
shouldAutoSplat,
|
||||||
|
Splat,
|
||||||
|
START_SPLATS,
|
||||||
|
Uv,
|
||||||
|
VELOCITY_SIZE,
|
||||||
|
WORLD_SIZE
|
||||||
|
} from './fluid.model';
|
||||||
|
import {FluidParams, FluidSimulation} from './fluid-simulation';
|
||||||
|
|
||||||
|
interface SettingSlider {
|
||||||
|
key: keyof FluidSettings;
|
||||||
|
label: string;
|
||||||
|
model: WritableSignal<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MS_PER_SECOND = 1000;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-fluid',
|
||||||
|
imports: [
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
TranslatePipe,
|
||||||
|
BabylonCanvas,
|
||||||
|
MatButton,
|
||||||
|
SliderFieldComponent,
|
||||||
|
Information,
|
||||||
|
MetricsComponent
|
||||||
|
],
|
||||||
|
templateUrl: './fluid.component.html',
|
||||||
|
})
|
||||||
|
export class FluidComponent implements OnDestroy {
|
||||||
|
private currentSceneData: SceneEventData | null = null;
|
||||||
|
private simulation: FluidSimulation | null = null;
|
||||||
|
private canvas: HTMLCanvasElement | null = null;
|
||||||
|
private pointerAttached = false;
|
||||||
|
private pressed = false;
|
||||||
|
private pointer: Uv | null = null;
|
||||||
|
private lastPointer: Uv | null = null;
|
||||||
|
private lastInput = 0;
|
||||||
|
private lastAuto = 0;
|
||||||
|
private readonly queue: Splat[] = [];
|
||||||
|
private readonly pointerLeaveHandler = () => { this.pressed = false; };
|
||||||
|
private readonly fpsCounter = new FpsCounter();
|
||||||
|
|
||||||
|
readonly limits = SETTINGS_LIMITS;
|
||||||
|
|
||||||
|
readonly backend = signal('–');
|
||||||
|
readonly unsupported = signal(false);
|
||||||
|
|
||||||
|
readonly velocityDissipation = signal(DEFAULT_SETTINGS.velocityDissipation);
|
||||||
|
readonly dyeFade = signal(DEFAULT_SETTINGS.dyeFade);
|
||||||
|
readonly pressureIterations = signal(DEFAULT_SETTINGS.pressureIterations);
|
||||||
|
readonly force = signal(DEFAULT_SETTINGS.force);
|
||||||
|
readonly splatRadius = signal(DEFAULT_SETTINGS.splatRadius);
|
||||||
|
|
||||||
|
readonly sliders: SettingSlider[] = [
|
||||||
|
{key: 'velocityDissipation', label: 'FLUID.VELOCITY_DISSIPATION', model: this.velocityDissipation},
|
||||||
|
{key: 'dyeFade', label: 'FLUID.DYE_FADE', model: this.dyeFade},
|
||||||
|
{key: 'pressureIterations', label: 'FLUID.PRESSURE_ITERATIONS', model: this.pressureIterations},
|
||||||
|
{key: 'force', label: 'FLUID.FORCE', model: this.force},
|
||||||
|
{key: 'splatRadius', label: 'FLUID.SPLAT_RADIUS', model: this.splatRadius}
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
|
||||||
|
{label: 'METRICS.BACKEND', value: this.backend()},
|
||||||
|
{label: 'METRICS.GRID', value: `${VELOCITY_SIZE} × ${VELOCITY_SIZE} / ${DYE_SIZE} × ${DYE_SIZE}`},
|
||||||
|
{label: 'METRICS.PRESSURE_ITERATIONS', value: clampIterations(this.pressureIterations()), fractionDigits: 0}
|
||||||
|
]);
|
||||||
|
|
||||||
|
readonly renderConfig: RenderConfig = {
|
||||||
|
mode: '2D',
|
||||||
|
initialViewSize: WORLD_SIZE
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'FLUID.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'FLUID.EXPLANATION.STAM_TITLE',
|
||||||
|
description: 'FLUID.EXPLANATION.STAM',
|
||||||
|
link: UrlConstants.STABLE_FLUIDS_PAPER,
|
||||||
|
linkLabel: 'Jos Stam (Stable Fluids)',
|
||||||
|
translateName: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FLUID.EXPLANATION.NAVIER_STOKES_TITLE',
|
||||||
|
description: 'FLUID.EXPLANATION.NAVIER_STOKES',
|
||||||
|
link: UrlConstants.NAVIER_STOKES_WIKI,
|
||||||
|
translateName: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'FLUID.EXPLANATION.GPU_TITLE',
|
||||||
|
description: 'FLUID.EXPLANATION.GPU',
|
||||||
|
link: UrlConstants.GPU_GEMS_FLUIDS,
|
||||||
|
linkLabel: 'GPU Gems, chapter 38',
|
||||||
|
translateName: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'FLUID.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: '',
|
||||||
|
disclaimerListEntry: ['FLUID.EXPLANATION.DISCLAIMER_1', 'FLUID.EXPLANATION.DISCLAIMER_2', 'FLUID.EXPLANATION.DISCLAIMER_3'],
|
||||||
|
howItWorks: {
|
||||||
|
intro: 'FLUID.HOW_IT_WORKS.INTRO',
|
||||||
|
items: ['FLUID.HOW_IT_WORKS.ITEM_1', 'FLUID.HOW_IT_WORKS.ITEM_2', 'FLUID.HOW_IT_WORKS.ITEM_3', 'FLUID.HOW_IT_WORKS.ITEM_4', 'FLUID.HOW_IT_WORKS.ITEM_5']
|
||||||
|
},
|
||||||
|
sources: SOURCE_PATHS.FLUID
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.canvas?.removeEventListener('pointerleave', this.pointerLeaveHandler);
|
||||||
|
this.simulation?.dispose();
|
||||||
|
this.simulation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSceneReady(event: SceneEventData): void {
|
||||||
|
this.currentSceneData = event;
|
||||||
|
const backend = event.gpuTier === 'webgpu' ? 'WebGPU' : 'WebGL';
|
||||||
|
const type = stateTextureTypeFor(event.engine.getCaps());
|
||||||
|
this.unsupported.set(!type);
|
||||||
|
if (!type) {
|
||||||
|
this.backend.set(backend);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.backend.set(`${backend} (${type === 'float' ? 'float32' : 'float16'})`);
|
||||||
|
this.simulation?.dispose();
|
||||||
|
this.simulation = new FluidSimulation();
|
||||||
|
this.simulation.init(event.scene, event.engine, type, () => this.currentParams());
|
||||||
|
this.lastInput = performance.now();
|
||||||
|
this.lastAuto = this.lastInput;
|
||||||
|
this.queueStartSplats();
|
||||||
|
event.scene.onAfterRenderObservable.clear();
|
||||||
|
event.scene.onAfterRenderObservable.add(() => this.fpsCounter.tick());
|
||||||
|
this.attachPointer(event.scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resize keeps the running simulation. Only a changed GPU tier needs a new one. */
|
||||||
|
onSceneResized(event: SceneEventData): void {
|
||||||
|
if (event.gpuTier !== this.currentSceneData?.gpuTier) {
|
||||||
|
this.onSceneReady(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.simulation?.reset();
|
||||||
|
this.queueStartSplats();
|
||||||
|
}
|
||||||
|
|
||||||
|
private queueStartSplats(): void {
|
||||||
|
const timeSec = performance.now() / MS_PER_SECOND;
|
||||||
|
for (let i = 0; i < START_SPLATS; i++) {
|
||||||
|
const color = dyeColor(timeSec + (i * HUE_PERIOD_SEC) / START_SPLATS);
|
||||||
|
this.queue.push(randomSplat(color, this.force()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called once per drawn frame. Drains the queued splats and adds the pointer and idle splats. */
|
||||||
|
private currentParams(): FluidParams {
|
||||||
|
const now = performance.now();
|
||||||
|
const color = dyeColor(now / MS_PER_SECOND);
|
||||||
|
const splats = this.queue.splice(0);
|
||||||
|
|
||||||
|
if (this.pressed && this.pointer && this.lastPointer) {
|
||||||
|
const dragSplat = pointerSplat(this.lastPointer, this.pointer, this.force(), color);
|
||||||
|
if (dragSplat) {
|
||||||
|
splats.push(dragSplat);
|
||||||
|
}
|
||||||
|
this.lastPointer = this.pointer;
|
||||||
|
}
|
||||||
|
if (shouldAutoSplat(now, this.lastInput, this.lastAuto)) {
|
||||||
|
splats.push(randomSplat(color, this.force()));
|
||||||
|
this.lastAuto = now;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
velocityDissipation: this.velocityDissipation(),
|
||||||
|
dyeFade: this.dyeFade(),
|
||||||
|
pressureIterations: this.pressureIterations(),
|
||||||
|
force: this.force(),
|
||||||
|
splatRadius: this.splatRadius()
|
||||||
|
},
|
||||||
|
splats
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** onSceneReady runs again when the GPU tier changes, so the observer must be added only once. */
|
||||||
|
private attachPointer(scene: Scene): void {
|
||||||
|
if (this.pointerAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pointerAttached = true;
|
||||||
|
|
||||||
|
this.canvas = scene.getEngine().getRenderingCanvas();
|
||||||
|
this.canvas?.addEventListener('pointerleave', this.pointerLeaveHandler);
|
||||||
|
|
||||||
|
scene.onPointerObservable.add((info) => {
|
||||||
|
if (info.type === PointerEventTypes.POINTERUP) {
|
||||||
|
this.pressed = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isDown = info.type === PointerEventTypes.POINTERDOWN;
|
||||||
|
if (isDown) {
|
||||||
|
this.pressed = true;
|
||||||
|
} else if (info.type !== PointerEventTypes.POINTERMOVE || !this.pressed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const event = info.event as PointerEvent;
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
this.pointer = pointerToUv(event.offsetX, event.offsetY, target.clientWidth, target.clientHeight);
|
||||||
|
this.lastInput = performance.now();
|
||||||
|
if (isDown) {
|
||||||
|
this.lastPointer = this.pointer;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
AUTO_SPLAT_INTERVAL_MS, clampIterations, DEFAULT_SETTINGS, dyeColor, DYE_INTENSITY, FluidSettings, HUE_PERIOD_SEC, IDLE_MS,
|
||||||
|
MAX_PRESSURE_ITERATIONS, pointerSplat, randomSplat, SETTINGS_LIMITS, shouldAutoSplat
|
||||||
|
} from './fluid.model';
|
||||||
|
|
||||||
|
const keys = Object.keys(SETTINGS_LIMITS) as (keyof FluidSettings)[];
|
||||||
|
const white = {r: 1, g: 1, b: 1};
|
||||||
|
|
||||||
|
function onGrid(key: keyof FluidSettings, value: number): boolean {
|
||||||
|
const {min, step} = SETTINGS_LIMITS[key];
|
||||||
|
const steps = (value - min) / step;
|
||||||
|
return Math.abs(steps - Math.round(steps)) < 1e-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('fluid model', () => {
|
||||||
|
describe('slider grid', () => {
|
||||||
|
it.each(keys)('puts the maximum of %s on the step grid', (key) => {
|
||||||
|
expect(onGrid(key, SETTINGS_LIMITS[key].max)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(keys)('puts the default of %s on the step grid', (key) => {
|
||||||
|
expect(onGrid(key, DEFAULT_SETTINGS[key])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(keys)('keeps the default of %s inside the limits', (key) => {
|
||||||
|
expect(DEFAULT_SETTINGS[key]).toBeGreaterThanOrEqual(SETTINGS_LIMITS[key].min);
|
||||||
|
expect(DEFAULT_SETTINGS[key]).toBeLessThanOrEqual(SETTINGS_LIMITS[key].max);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clampIterations', () => {
|
||||||
|
it('keeps the slider inside the code limit', () => {
|
||||||
|
expect(SETTINGS_LIMITS.pressureIterations.max).toBeLessThanOrEqual(MAX_PRESSURE_ITERATIONS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps and rounds', () => {
|
||||||
|
expect(clampIterations(1000)).toBe(MAX_PRESSURE_ITERATIONS);
|
||||||
|
expect(clampIterations(0)).toBe(1);
|
||||||
|
expect(clampIterations(19.6)).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dyeColor', () => {
|
||||||
|
it('repeats after one hue period', () => {
|
||||||
|
const a = dyeColor(1.3);
|
||||||
|
const b = dyeColor(1.3 + HUE_PERIOD_SEC);
|
||||||
|
expect(b.r).toBeCloseTo(a.r);
|
||||||
|
expect(b.g).toBeCloseTo(a.g);
|
||||||
|
expect(b.b).toBeCloseTo(a.b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('differs half a period later', () => {
|
||||||
|
const a = dyeColor(0);
|
||||||
|
const b = dyeColor(HUE_PERIOD_SEC / 2);
|
||||||
|
expect(Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b)).toBeGreaterThan(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every channel between 0 and the dye intensity', () => {
|
||||||
|
for (let t = 0; t < HUE_PERIOD_SEC; t += 0.25) {
|
||||||
|
const color = dyeColor(t);
|
||||||
|
for (const value of [color.r, color.g, color.b]) {
|
||||||
|
expect(value).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(value).toBeLessThanOrEqual(DYE_INTENSITY + 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pointerSplat', () => {
|
||||||
|
it('pushes right for a drag to the right', () => {
|
||||||
|
const splat = pointerSplat({u: 0.2, v: 0.5}, {u: 0.3, v: 0.5}, 1000, white)!;
|
||||||
|
expect(splat.dx).toBeGreaterThan(0);
|
||||||
|
expect(splat.dy).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushes along growing v for a drag down', () => {
|
||||||
|
expect(pointerSplat({u: 0.5, v: 0.2}, {u: 0.5, v: 0.3}, 1000, white)!.dy).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null without movement', () => {
|
||||||
|
expect(pointerSplat({u: 0.4, v: 0.4}, {u: 0.4, v: 0.4}, 1000, white)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales with the force', () => {
|
||||||
|
const from = {u: 0.2, v: 0.2};
|
||||||
|
const to = {u: 0.3, v: 0.2};
|
||||||
|
const weak = pointerSplat(from, to, 1000, white)!;
|
||||||
|
const strong = pointerSplat(from, to, 2000, white)!;
|
||||||
|
expect(strong.dx).toBeCloseTo(weak.dx * 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('randomSplat', () => {
|
||||||
|
it.each([0, 0.5, 1])('keeps u and v inside 0.1..0.9 for random %s', (value) => {
|
||||||
|
const splat = randomSplat(white, 6000, () => value);
|
||||||
|
expect(splat.u).toBeGreaterThanOrEqual(0.1 - 1e-9);
|
||||||
|
expect(splat.u).toBeLessThanOrEqual(0.9 + 1e-9);
|
||||||
|
expect(splat.v).toBeGreaterThanOrEqual(0.1 - 1e-9);
|
||||||
|
expect(splat.v).toBeLessThanOrEqual(0.9 + 1e-9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldAutoSplat', () => {
|
||||||
|
it('waits for the idle time', () => {
|
||||||
|
expect(shouldAutoSplat(IDLE_MS - 1, 0, 0)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires after idle time and interval', () => {
|
||||||
|
const now = Math.max(IDLE_MS, AUTO_SPLAT_INTERVAL_MS);
|
||||||
|
expect(shouldAutoSplat(now, 0, 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for the interval after an automatic splat', () => {
|
||||||
|
const now = IDLE_MS + AUTO_SPLAT_INTERVAL_MS;
|
||||||
|
expect(shouldAutoSplat(now, 0, now)).toBe(false);
|
||||||
|
expect(shouldAutoSplat(now + AUTO_SPLAT_INTERVAL_MS, 0, now)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
export const WORLD_SIZE = 100;
|
||||||
|
export const VELOCITY_SIZE = 128;
|
||||||
|
export const DYE_SIZE = 512;
|
||||||
|
/** Fixed time step in seconds. At 30 FPS the fluid runs at half speed, which is stabler than a variable step. */
|
||||||
|
export const SIM_DT = 1 / 60;
|
||||||
|
export const CURL_STRENGTH = 30;
|
||||||
|
export const MAX_PRESSURE_ITERATIONS = 50;
|
||||||
|
export const START_SPLATS = 5;
|
||||||
|
export const IDLE_MS = 3000;
|
||||||
|
export const AUTO_SPLAT_INTERVAL_MS = 2500;
|
||||||
|
export const HUE_PERIOD_SEC = 10;
|
||||||
|
export const DYE_INTENSITY = 0.8;
|
||||||
|
|
||||||
|
const MIN_RANDOM_POSITION = 0.1;
|
||||||
|
const RANDOM_POSITION_RANGE = 0.8;
|
||||||
|
const RANDOM_SPLAT_FORCE_FACTOR = 0.1;
|
||||||
|
/** Start and idle splats are brighter than a drag, which adds dye every frame. */
|
||||||
|
const RANDOM_SPLAT_DYE_BOOST = 10;
|
||||||
|
|
||||||
|
export interface FluidSettings {
|
||||||
|
velocityDissipation: number;
|
||||||
|
dyeFade: number;
|
||||||
|
pressureIterations: number;
|
||||||
|
force: number;
|
||||||
|
splatRadius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SETTINGS_LIMITS: Record<keyof FluidSettings, {min: number; max: number; step: number}> = {
|
||||||
|
velocityDissipation: {min: 0, max: 4, step: 0.05},
|
||||||
|
dyeFade: {min: 0, max: 4, step: 0.05},
|
||||||
|
pressureIterations: {min: 1, max: 50, step: 1},
|
||||||
|
force: {min: 1000, max: 10000, step: 500},
|
||||||
|
splatRadius: {min: 0.05, max: 1, step: 0.05}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: FluidSettings = {
|
||||||
|
velocityDissipation: 0.2,
|
||||||
|
dyeFade: 0.5,
|
||||||
|
pressureIterations: 20,
|
||||||
|
force: 6000,
|
||||||
|
splatRadius: 0.25
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Color {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pointer position in uv units, 0..1, v = 0 at the top. */
|
||||||
|
export interface Uv {
|
||||||
|
u: number;
|
||||||
|
v: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Velocity in grid cells per second. */
|
||||||
|
export interface Splat extends Uv, Color {
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampIterations(n: number): number {
|
||||||
|
return Math.min(MAX_PRESSURE_ITERATIONS, Math.max(1, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fully saturated color that cycles through all hues every HUE_PERIOD_SEC. */
|
||||||
|
export function dyeColor(timeSec: number): Color {
|
||||||
|
const hue = (((timeSec % HUE_PERIOD_SEC) + HUE_PERIOD_SEC) % HUE_PERIOD_SEC) / HUE_PERIOD_SEC;
|
||||||
|
const sector = hue * 6;
|
||||||
|
const channel = (n: number): number => {
|
||||||
|
const k = (n + sector) % 6;
|
||||||
|
return (1 - Math.max(0, Math.min(k, 4 - k, 1))) * DYE_INTENSITY;
|
||||||
|
};
|
||||||
|
return {r: channel(5), g: channel(3), b: channel(1)};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The drag becomes a velocity in cells per second. Null when the pointer did not move. */
|
||||||
|
export function pointerSplat(from: Uv, to: Uv, force: number, color: Color): Splat | null {
|
||||||
|
const du = to.u - from.u;
|
||||||
|
const dv = to.v - from.v;
|
||||||
|
if (du === 0 && dv === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {u: to.u, v: to.v, dx: du * force, dy: dv * force, ...color};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomSplat(color: Color, force: number, random: () => number = Math.random): Splat {
|
||||||
|
const u = MIN_RANDOM_POSITION + RANDOM_POSITION_RANGE * random();
|
||||||
|
const v = MIN_RANDOM_POSITION + RANDOM_POSITION_RANGE * random();
|
||||||
|
const angle = 2 * Math.PI * random();
|
||||||
|
const speed = force * RANDOM_SPLAT_FORCE_FACTOR;
|
||||||
|
return {u, v, dx: Math.cos(angle) * speed, dy: Math.sin(angle) * speed,
|
||||||
|
r: color.r * RANDOM_SPLAT_DYE_BOOST, g: color.g * RANDOM_SPLAT_DYE_BOOST, b: color.b * RANDOM_SPLAT_DYE_BOOST};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldAutoSplat(now: number, lastInput: number, lastAuto: number): boolean {
|
||||||
|
return now - lastInput >= IDLE_MS && now - lastAuto >= AUTO_SPLAT_INTERVAL_MS;
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import {DYE_SIZE, VELOCITY_SIZE} from './fluid.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GLSL shaders for the stable fluids demo. All targets are float: velocity in RG (grid cells per second),
|
||||||
|
* pressure, divergence and curl in R, dye in RGB.
|
||||||
|
*
|
||||||
|
* Walls: every sampled coordinate is clamped to the cell centers inside the field (clampToField), so a read past
|
||||||
|
* the edge returns the edge cell. This gives zero normal pressure gradient.
|
||||||
|
* Sampler helpers carry `#define inline` so Babylon inlines them on WebGPU, where a sampler cannot be passed to a function.
|
||||||
|
* The divergence shader adds the free-slip wall: the normal velocity is mirrored at the edge.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VELOCITY_GRID = `
|
||||||
|
#define VELOCITY_SIZE ${VELOCITY_SIZE}.0
|
||||||
|
#define VELOCITY_TEXEL (1.0 / VELOCITY_SIZE)
|
||||||
|
|
||||||
|
vec2 clampToField(vec2 uv, float texel) {
|
||||||
|
return clamp(uv, vec2(0.5 * texel), vec2(1.0 - 0.5 * texel));
|
||||||
|
}
|
||||||
|
|
||||||
|
#define inline
|
||||||
|
vec4 fetch(sampler2D tex, vec2 uv) {
|
||||||
|
return texture2D(tex, clampToField(uv, VELOCITY_TEXEL));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Float32 textures cannot use hardware linear filtering everywhere, so the four nearest cells are blended here.
|
||||||
|
* The corner cells are clamped to the field, so a lookup outside returns the edge cell.
|
||||||
|
*/
|
||||||
|
const BILERP = `
|
||||||
|
#define inline
|
||||||
|
vec4 bilerp(sampler2D tex, vec2 uv, float size) {
|
||||||
|
vec2 position = uv * size - 0.5;
|
||||||
|
vec2 blend = fract(position);
|
||||||
|
vec2 origin = floor(position);
|
||||||
|
vec2 low = (clamp(origin, 0.0, size - 1.0) + 0.5) / size;
|
||||||
|
vec2 high = (clamp(origin + 1.0, 0.0, size - 1.0) + 0.5) / size;
|
||||||
|
vec4 bottom = mix(texture2D(tex, low), texture2D(tex, vec2(high.x, low.y)), blend.x);
|
||||||
|
vec4 top = mix(texture2D(tex, vec2(low.x, high.y)), texture2D(tex, high), blend.x);
|
||||||
|
return mix(bottom, top, blend.y);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CLEAR_FRAGMENT_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = vec4(0.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Adds a gaussian blob of value around point. The same shader serves velocity (value = dx, dy, 0) and dye. */
|
||||||
|
export const SPLAT_FRAGMENT_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
uniform sampler2D target;
|
||||||
|
uniform vec2 point;
|
||||||
|
uniform vec3 value;
|
||||||
|
uniform float radius;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 offset = vUV - point;
|
||||||
|
float weight = exp(-dot(offset, offset) / (radius / 100.0));
|
||||||
|
gl_FragColor = vec4(texture2D(target, vUV).rgb + value * weight, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CURL_FRAGMENT_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
${VELOCITY_GRID}
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
uniform sampler2D velocity;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
float left = fetch(velocity, vUV - vec2(VELOCITY_TEXEL, 0.0)).y;
|
||||||
|
float right = fetch(velocity, vUV + vec2(VELOCITY_TEXEL, 0.0)).y;
|
||||||
|
float top = fetch(velocity, vUV + vec2(0.0, VELOCITY_TEXEL)).x;
|
||||||
|
float bottom = fetch(velocity, vUV - vec2(0.0, VELOCITY_TEXEL)).x;
|
||||||
|
gl_FragColor = vec4(0.5 * (right - left - top + bottom), 0.0, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Vorticity confinement: pushes the fluid along the rotated gradient of |curl|, so small swirls do not fade. */
|
||||||
|
export const VORTICITY_FRAGMENT_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
${VELOCITY_GRID}
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
uniform sampler2D velocity;
|
||||||
|
uniform sampler2D curl;
|
||||||
|
uniform float curlStrength;
|
||||||
|
uniform float dt;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
float left = fetch(curl, vUV - vec2(VELOCITY_TEXEL, 0.0)).x;
|
||||||
|
float right = fetch(curl, vUV + vec2(VELOCITY_TEXEL, 0.0)).x;
|
||||||
|
float top = fetch(curl, vUV + vec2(0.0, VELOCITY_TEXEL)).x;
|
||||||
|
float bottom = fetch(curl, vUV - vec2(0.0, VELOCITY_TEXEL)).x;
|
||||||
|
float center = fetch(curl, vUV).x;
|
||||||
|
|
||||||
|
vec2 force = 0.5 * vec2(abs(top) - abs(bottom), abs(right) - abs(left));
|
||||||
|
force /= length(force) + 0.0001;
|
||||||
|
force *= curlStrength * center;
|
||||||
|
force.y *= -1.0;
|
||||||
|
|
||||||
|
vec2 speed = fetch(velocity, vUV).xy + force * dt;
|
||||||
|
gl_FragColor = vec4(speed, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DIVERGENCE_FRAGMENT_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
${VELOCITY_GRID}
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
uniform sampler2D velocity;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 uvLeft = vUV - vec2(VELOCITY_TEXEL, 0.0);
|
||||||
|
vec2 uvRight = vUV + vec2(VELOCITY_TEXEL, 0.0);
|
||||||
|
vec2 uvTop = vUV + vec2(0.0, VELOCITY_TEXEL);
|
||||||
|
vec2 uvBottom = vUV - vec2(0.0, VELOCITY_TEXEL);
|
||||||
|
|
||||||
|
vec2 center = fetch(velocity, vUV).xy;
|
||||||
|
float left = fetch(velocity, uvLeft).x;
|
||||||
|
float right = fetch(velocity, uvRight).x;
|
||||||
|
float top = fetch(velocity, uvTop).y;
|
||||||
|
float bottom = fetch(velocity, uvBottom).y;
|
||||||
|
|
||||||
|
// Free-slip wall: past the edge the normal velocity mirrors the cell, so the average at the wall is zero.
|
||||||
|
if (uvLeft.x < 0.0) { left = -center.x; }
|
||||||
|
if (uvRight.x > 1.0) { right = -center.x; }
|
||||||
|
if (uvTop.y > 1.0) { top = -center.y; }
|
||||||
|
if (uvBottom.y < 0.0) { bottom = -center.y; }
|
||||||
|
|
||||||
|
gl_FragColor = vec4(0.5 * (right - left + top - bottom), 0.0, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** One Jacobi step of the pressure Poisson equation. */
|
||||||
|
export const PRESSURE_FRAGMENT_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
${VELOCITY_GRID}
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
uniform sampler2D pressure;
|
||||||
|
uniform sampler2D divergence;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
float left = fetch(pressure, vUV - vec2(VELOCITY_TEXEL, 0.0)).x;
|
||||||
|
float right = fetch(pressure, vUV + vec2(VELOCITY_TEXEL, 0.0)).x;
|
||||||
|
float top = fetch(pressure, vUV + vec2(0.0, VELOCITY_TEXEL)).x;
|
||||||
|
float bottom = fetch(pressure, vUV - vec2(0.0, VELOCITY_TEXEL)).x;
|
||||||
|
float divergenceHere = fetch(divergence, vUV).x;
|
||||||
|
gl_FragColor = vec4((left + right + top + bottom - divergenceHere) * 0.25, 0.0, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GRADIENT_SUBTRACT_FRAGMENT_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
${VELOCITY_GRID}
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
uniform sampler2D pressure;
|
||||||
|
uniform sampler2D velocity;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
float left = fetch(pressure, vUV - vec2(VELOCITY_TEXEL, 0.0)).x;
|
||||||
|
float right = fetch(pressure, vUV + vec2(VELOCITY_TEXEL, 0.0)).x;
|
||||||
|
float top = fetch(pressure, vUV + vec2(0.0, VELOCITY_TEXEL)).x;
|
||||||
|
float bottom = fetch(pressure, vUV - vec2(0.0, VELOCITY_TEXEL)).x;
|
||||||
|
vec2 speed = fetch(velocity, vUV).xy - 0.5 * vec2(right - left, top - bottom);
|
||||||
|
gl_FragColor = vec4(speed, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Semi-Lagrangian advection of any field by the velocity, with decay per second: value / (1 + rate * dt). */
|
||||||
|
export const ADVECT_FRAGMENT_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
${VELOCITY_GRID}
|
||||||
|
${BILERP}
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
uniform sampler2D velocity;
|
||||||
|
uniform sampler2D source;
|
||||||
|
uniform float sourceSize;
|
||||||
|
uniform float dt;
|
||||||
|
uniform float dissipation;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 speed = bilerp(velocity, vUV, VELOCITY_SIZE).xy;
|
||||||
|
vec2 origin = clampToField(vUV - dt * speed * VELOCITY_TEXEL, 1.0 / sourceSize);
|
||||||
|
vec4 result = bilerp(source, origin, sourceSize);
|
||||||
|
gl_FragColor = result / (1.0 + dissipation * dt);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DISPLAY_FRAGMENT_GLSL = `
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
#define DYE_SIZE ${DYE_SIZE}.0
|
||||||
|
|
||||||
|
${BILERP}
|
||||||
|
|
||||||
|
varying vec2 vUV;
|
||||||
|
|
||||||
|
uniform sampler2D dye;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// The plane faces the camera rotated by 180 degrees, so both axes are flipped.
|
||||||
|
vec2 uv = vec2(1.0) - vUV;
|
||||||
|
vec3 color = bilerp(dye, uv, DYE_SIZE).rgb;
|
||||||
|
gl_FragColor = vec4(color / (1.0 + color), 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -47,6 +47,8 @@
|
|||||||
<span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
|
<span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
|
|
||||||
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded-sm border-l-[5px] font-medium min-w-[300px] items-center"
|
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded-sm border-l-[5px] font-medium min-w-[300px] items-center"
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
'border-l-gray-500 bg-app-bg': solutionStatus() === 'INCOMPLETE',
|
'border-l-gray-500 bg-app-bg': solutionStatus() === 'INCOMPLETE',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {AfterViewInit, Component, ElementRef, inject, signal, ViewChild} from '@angular/core';
|
import {AfterViewInit, Component, computed, ElementRef, inject, PLATFORM_ID, signal, ViewChild} from '@angular/core';
|
||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule, isPlatformBrowser} from '@angular/common';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
import {MatButtonModule} from '@angular/material/button';
|
import {MatButtonModule} from '@angular/material/button';
|
||||||
import {MatCardModule} from '@angular/material/card';
|
import {MatCardModule} from '@angular/material/card';
|
||||||
@@ -8,12 +8,15 @@ import {MatInputModule} from '@angular/material/input';
|
|||||||
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
|
||||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||||
|
|
||||||
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorMap, FourColorNode, SolutionStatus} from './four-color.models';
|
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_ATTEMPTS, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorMap, FourColorNode, SolutionStatus} from './four-color.models';
|
||||||
import {AlgorithmInformation} from '../information/information.models';
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {SOURCE_PATHS} from '../information/source-paths';
|
||||||
import {Information} from '../information/information';
|
import {Information} from '../information/information';
|
||||||
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
|
||||||
import {FourColorService} from './service/four-color.service';
|
import {FourColorService} from './service/four-color.service';
|
||||||
import {UrlConstants} from '../../../constants/UrlConstants';
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {LanguageService} from '../../../service/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-four-color',
|
selector: 'app-four-color',
|
||||||
@@ -26,15 +29,18 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
|||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
Information
|
Information,
|
||||||
|
MetricsComponent
|
||||||
],
|
],
|
||||||
templateUrl: './four-color.component.html',
|
templateUrl: './four-color.component.html',
|
||||||
styleUrl: './four-color.component.scss'
|
styleUrl: './four-color.component.scss'
|
||||||
})
|
})
|
||||||
export class FourColorComponent implements AfterViewInit {
|
export class FourColorComponent implements AfterViewInit {
|
||||||
private readonly fourColorService = inject(FourColorService);
|
private readonly fourColorService = inject(FourColorService);
|
||||||
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly snackBar = inject(MatSnackBar);
|
private readonly snackBar = inject(MatSnackBar);
|
||||||
|
private readonly language = inject(LanguageService);
|
||||||
|
|
||||||
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
|
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
|
||||||
readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
|
readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
|
||||||
@@ -56,13 +62,31 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
'FOUR_COLOR.EXPLANATION.DISCLAIMER_2',
|
'FOUR_COLOR.EXPLANATION.DISCLAIMER_2',
|
||||||
'FOUR_COLOR.EXPLANATION.DISCLAIMER_3',
|
'FOUR_COLOR.EXPLANATION.DISCLAIMER_3',
|
||||||
'FOUR_COLOR.EXPLANATION.DISCLAIMER_4'
|
'FOUR_COLOR.EXPLANATION.DISCLAIMER_4'
|
||||||
]
|
],
|
||||||
|
howItWorks: {intro: 'FOUR_COLOR.HOW_IT_WORKS.INTRO', items: ['FOUR_COLOR.HOW_IT_WORKS.ITEM_1', 'FOUR_COLOR.HOW_IT_WORKS.ITEM_2', 'FOUR_COLOR.HOW_IT_WORKS.ITEM_3', 'FOUR_COLOR.HOW_IT_WORKS.ITEM_4']},
|
||||||
|
sources: SOURCE_PATHS.FOUR_COLOR
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly gridRows = signal(DEFAULT_GRID_ROWS);
|
readonly gridRows = signal(DEFAULT_GRID_ROWS);
|
||||||
readonly gridCols = signal(DEFAULT_GRID_COLS);
|
readonly gridCols = signal(DEFAULT_GRID_COLS);
|
||||||
map: FourColorMap = {grid: [], regions: []};
|
map: FourColorMap = {grid: [], regions: []};
|
||||||
executionTime = 0;
|
readonly executionTime = signal(0);
|
||||||
|
readonly attempts = signal(0);
|
||||||
|
readonly gaveUp = signal(false);
|
||||||
|
readonly regionCount = signal(0);
|
||||||
|
readonly borderCount = signal(0);
|
||||||
|
private readonly attemptsLabel = computed(() => {
|
||||||
|
if (!this.gaveUp()) {
|
||||||
|
return this.attempts();
|
||||||
|
}
|
||||||
|
return `> ${this.attempts().toLocaleString(this.language.lang())}`;
|
||||||
|
});
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.REGIONS', value: this.regionCount()},
|
||||||
|
{label: 'METRICS.BORDERS', value: this.borderCount()},
|
||||||
|
{label: 'METRICS.ATTEMPTS', value: this.attemptsLabel()},
|
||||||
|
{label: 'FOUR_COLOR.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'}
|
||||||
|
]);
|
||||||
readonly solutionStatus = signal<SolutionStatus>('INCOMPLETE');
|
readonly solutionStatus = signal<SolutionStatus>('INCOMPLETE');
|
||||||
|
|
||||||
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
@@ -70,6 +94,9 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
private nodeSize = 0;
|
private nodeSize = 0;
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
|
if (!this.isBrowser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
|
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
|
||||||
this.initializeGrid();
|
this.initializeGrid();
|
||||||
}
|
}
|
||||||
@@ -84,6 +111,11 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
initializeGrid(): void {
|
initializeGrid(): void {
|
||||||
this.map = this.fourColorService.createMap(this.gridRows(), this.gridCols());
|
this.map = this.fourColorService.createMap(this.gridRows(), this.gridCols());
|
||||||
this.solutionStatus.set('INCOMPLETE');
|
this.solutionStatus.set('INCOMPLETE');
|
||||||
|
this.regionCount.set(this.map.regions.length);
|
||||||
|
this.borderCount.set(this.fourColorService.countBorders(this.map.regions));
|
||||||
|
this.attempts.set(0);
|
||||||
|
this.gaveUp.set(false);
|
||||||
|
this.executionTime.set(0);
|
||||||
this.resizeCanvas();
|
this.resizeCanvas();
|
||||||
this.drawGrid();
|
this.drawGrid();
|
||||||
}
|
}
|
||||||
@@ -208,19 +240,29 @@ export class FourColorComponent implements AfterViewInit {
|
|||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
this.resetColors();
|
this.resetColors();
|
||||||
|
|
||||||
const success = this.fourColorService.solve(this.map);
|
const {solved, gaveUp, attempts} = this.fourColorService.solve(this.map);
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
this.executionTime = endTime - startTime;
|
this.executionTime.set(endTime - startTime);
|
||||||
|
this.attempts.set(attempts);
|
||||||
|
this.gaveUp.set(gaveUp);
|
||||||
|
|
||||||
if (success) {
|
if (solved) {
|
||||||
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
|
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
|
||||||
this.drawGrid();
|
this.drawGrid();
|
||||||
|
} else if (gaveUp) {
|
||||||
|
this.showLimitReachedAlert();
|
||||||
} else {
|
} else {
|
||||||
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
|
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
|
||||||
this.snackBar.open(message, 'ALERT');
|
this.snackBar.open(message, 'ALERT');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private showLimitReachedAlert(): void {
|
||||||
|
const limit = MAX_ATTEMPTS.toLocaleString(this.language.lang());
|
||||||
|
const message = this.translate.instant('FOUR_COLOR.ALERT.LIMIT_REACHED', {limit});
|
||||||
|
this.snackBar.open(message, 'OK', {duration: 5000, horizontalPosition: 'center', verticalPosition: 'top'});
|
||||||
|
}
|
||||||
|
|
||||||
generateNewMap(): void {
|
generateNewMap(): void {
|
||||||
this.initializeGrid();
|
this.initializeGrid();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ export interface FourColorMap {
|
|||||||
|
|
||||||
export const CELLS_PER_REGION = 30;
|
export const CELLS_PER_REGION = 30;
|
||||||
export const COLOR_COUNT = 4;
|
export const COLOR_COUNT = 4;
|
||||||
|
export const MAX_ATTEMPTS = 100000;
|
||||||
@@ -80,7 +80,7 @@ describe('FourColorService', () => {
|
|||||||
it('colors K4 with four distinct colors and mirrors them to the grid', () => {
|
it('colors K4 with four distinct colors and mirrors them to the grid', () => {
|
||||||
const map = handBuiltMap(complete(4));
|
const map = handBuiltMap(complete(4));
|
||||||
|
|
||||||
expect(service.solve(map)).toBe(true);
|
expect(service.solve(map).solved).toBe(true);
|
||||||
|
|
||||||
expect(new Set(map.regions.map(r => r.color)).size).toBe(4);
|
expect(new Set(map.regions.map(r => r.color)).size).toBe(4);
|
||||||
map.regions.forEach((r, i) => expect(map.grid[0][i].color).toBe(r.color));
|
map.regions.forEach((r, i) => expect(map.grid[0][i].color).toBe(r.color));
|
||||||
@@ -89,20 +89,97 @@ describe('FourColorService', () => {
|
|||||||
it('fails on K5 and leaves every color at 0', () => {
|
it('fails on K5 and leaves every color at 0', () => {
|
||||||
const map = handBuiltMap(complete(5));
|
const map = handBuiltMap(complete(5));
|
||||||
|
|
||||||
expect(service.solve(map)).toBe(false);
|
expect(service.solve(map).solved).toBe(false);
|
||||||
|
|
||||||
expect(map.regions.every(r => r.color === 0)).toBe(true);
|
expect(map.regions.every(r => r.color === 0)).toBe(true);
|
||||||
expect(map.grid.flat().every(node => node.color === 0)).toBe(true);
|
expect(map.grid.flat().every(node => node.color === 0)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts one attempt per region on a chain of three regions', () => {
|
||||||
|
const map = handBuiltMap([region(0, 0, [1]), region(1, 0, [0, 2]), region(2, 0, [1])]);
|
||||||
|
|
||||||
|
expect(service.solve(map).attempts).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts undone assignments as attempts when the map is unsolvable', () => {
|
||||||
|
const map = handBuiltMap(complete(5));
|
||||||
|
|
||||||
|
expect(service.solve(map).attempts).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up at the attempt limit and resets every color', () => {
|
||||||
|
const map = handBuiltMap(complete(4));
|
||||||
|
|
||||||
|
const result = service.solve(map, 2);
|
||||||
|
|
||||||
|
expect(result.gaveUp).toBe(true);
|
||||||
|
expect(result.solved).toBe(false);
|
||||||
|
expect(result.attempts).toBe(2);
|
||||||
|
expect(map.regions.every(r => r.color === 0)).toBe(true);
|
||||||
|
expect(map.grid.flat().every(node => node.color === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not give up on an unsolvable map that exhausts before the limit', () => {
|
||||||
|
const result = service.solve(handBuiltMap(complete(5)));
|
||||||
|
|
||||||
|
expect(result.solved).toBe(false);
|
||||||
|
expect(result.gaveUp).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports gaveUp false when solved', () => {
|
||||||
|
expect(service.solve(handBuiltMap(complete(4))).gaveUp).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colors a wheel graph with few attempts', () => {
|
||||||
|
const rim = [1, 2, 3, 4, 5, 6];
|
||||||
|
const regions = [
|
||||||
|
region(0, 0, rim),
|
||||||
|
...rim.map((id, i) => region(id, 0, [0, rim[(i + 5) % 6], rim[(i + 1) % 6]]))
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.solve(handBuiltMap(regions));
|
||||||
|
|
||||||
|
expect(result.solved).toBe(true);
|
||||||
|
expect(result.attempts).toBeLessThanOrEqual(2 * regions.length);
|
||||||
|
expect(service.evaluate(regions)).toBe('SOLVED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('solves 20 random 38 x 38 maps with a valid coloring or gives up', () => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const map = service.createMap(38, 38);
|
||||||
|
|
||||||
|
const {solved, gaveUp} = service.solve(map);
|
||||||
|
|
||||||
|
expect(solved && gaveUp).toBe(false);
|
||||||
|
if (solved) {
|
||||||
|
expect(service.evaluate(map.regions)).toBe('SOLVED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites existing manual colors', () => {
|
||||||
|
const map = handBuiltMap([region(0, 1, [1]), region(1, 1, [0])]);
|
||||||
|
|
||||||
|
const result = service.solve(map);
|
||||||
|
|
||||||
|
expect(result.solved).toBe(true);
|
||||||
|
expect(service.evaluate(map.regions)).toBe('SOLVED');
|
||||||
|
});
|
||||||
|
|
||||||
it('solves a generated map', () => {
|
it('solves a generated map', () => {
|
||||||
const map = service.createMap(25, 25);
|
const map = service.createMap(25, 25);
|
||||||
|
|
||||||
expect(service.solve(map)).toBe(true);
|
expect(service.solve(map).solved).toBe(true);
|
||||||
expect(service.evaluate(map.regions)).toBe('SOLVED');
|
expect(service.evaluate(map.regions)).toBe('SOLVED');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('countBorders counts every shared border once', () => {
|
||||||
|
const chain = [region(0, 0, [1]), region(1, 0, [0, 2]), region(2, 0, [1])];
|
||||||
|
|
||||||
|
expect(service.countBorders(chain)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
describe('evaluate', () => {
|
describe('evaluate', () => {
|
||||||
it('is INCOMPLETE when nothing is colored', () => {
|
it('is INCOMPLETE when nothing is colored', () => {
|
||||||
expect(service.evaluate(complete(3))).toBe('INCOMPLETE');
|
expect(service.evaluate(complete(3))).toBe('INCOMPLETE');
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import {Injectable} from '@angular/core';
|
import {Injectable} from '@angular/core';
|
||||||
import {CELLS_PER_REGION, COLOR_COUNT, FourColorMap, FourColorNode, Region, SolutionStatus} from '../four-color.models';
|
import {CELLS_PER_REGION, COLOR_COUNT, MAX_ATTEMPTS, FourColorMap, FourColorNode, Region, SolutionStatus} from '../four-color.models';
|
||||||
import {GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
import {GridPos} from '../../../../shared/components/generic-grid/generic-grid';
|
||||||
import {SharedFunctions} from '../../../../shared/SharedFunctions';
|
import {SharedFunctions} from '../../../../shared/SharedFunctions';
|
||||||
|
|
||||||
|
export interface SolveResult {
|
||||||
|
solved: boolean;
|
||||||
|
/** true when the attempt limit stopped the search; colors are reset to 0 */
|
||||||
|
gaveUp: boolean;
|
||||||
|
attempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class FourColorService {
|
export class FourColorService {
|
||||||
|
|
||||||
|
private attempts = 0;
|
||||||
|
private gaveUp = false;
|
||||||
|
|
||||||
createMap(rows: number, cols: number): FourColorMap {
|
createMap(rows: number, cols: number): FourColorMap {
|
||||||
const grid: FourColorNode[][] = [];
|
const grid: FourColorNode[][] = [];
|
||||||
for (let r = 0; r < rows; r++) {
|
for (let r = 0; r < rows; r++) {
|
||||||
@@ -39,10 +49,17 @@ export class FourColorService {
|
|||||||
this.applyRegionColors(map);
|
this.applyRegionColors(map);
|
||||||
}
|
}
|
||||||
|
|
||||||
solve(map: FourColorMap): boolean {
|
solve(map: FourColorMap, maxAttempts = MAX_ATTEMPTS): SolveResult {
|
||||||
const solved = this.assignColors(map.regions, 0);
|
this.attempts = 0;
|
||||||
|
this.gaveUp = false;
|
||||||
|
this.resetColors(map);
|
||||||
|
const solved = this.assignColors(map.regions, maxAttempts);
|
||||||
this.applyRegionColors(map);
|
this.applyRegionColors(map);
|
||||||
return solved;
|
return {solved, gaveUp: this.gaveUp, attempts: this.attempts};
|
||||||
|
}
|
||||||
|
|
||||||
|
countBorders(regions: Region[]): number {
|
||||||
|
return regions.reduce((sum, region) => sum + region.neighbors.size, 0) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluate(regions: Region[]): SolutionStatus {
|
evaluate(regions: Region[]): SolutionStatus {
|
||||||
@@ -129,24 +146,59 @@ export class FourColorService {
|
|||||||
return neighbors;
|
return neighbors;
|
||||||
}
|
}
|
||||||
|
|
||||||
private assignColors(regions: Region[], regionIndex: number): boolean {
|
private assignColors(regions: Region[], maxAttempts: number): boolean {
|
||||||
if (regionIndex === regions.length) {
|
const region = this.nextRegion(regions);
|
||||||
|
if (region === undefined) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const region = regions[regionIndex];
|
|
||||||
for (let color = 1; color <= COLOR_COUNT; color++) {
|
for (let color = 1; color <= COLOR_COUNT; color++) {
|
||||||
if (this.isColorValid(regions, region, color)) {
|
if (!this.isColorValid(regions, region, color)) {
|
||||||
region.color = color;
|
continue;
|
||||||
if (this.assignColors(regions, regionIndex + 1)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
region.color = 0;
|
|
||||||
}
|
}
|
||||||
|
if (this.attempts >= maxAttempts) {
|
||||||
|
this.gaveUp = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.attempts++;
|
||||||
|
region.color = color;
|
||||||
|
if (this.assignColors(regions, maxAttempts)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
region.color = 0;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DSATUR: most constrained region first, so dead ends show up early
|
||||||
|
private nextRegion(regions: Region[]): Region | undefined {
|
||||||
|
let best: Region | undefined;
|
||||||
|
let bestColors = -1;
|
||||||
|
for (const region of regions) {
|
||||||
|
if (region.color !== 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const colors = this.countNeighborColors(regions, region);
|
||||||
|
const winsTie = colors === bestColors && region.neighbors.size > best!.neighbors.size;
|
||||||
|
if (colors > bestColors || winsTie) {
|
||||||
|
best = region;
|
||||||
|
bestColors = colors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private countNeighborColors(regions: Region[], region: Region): number {
|
||||||
|
const colors = new Set<number>();
|
||||||
|
for (const neighborId of region.neighbors) {
|
||||||
|
const color = regions[neighborId].color;
|
||||||
|
if (color !== 0) {
|
||||||
|
colors.add(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return colors.size;
|
||||||
|
}
|
||||||
|
|
||||||
private isColorValid(regions: Region[], region: Region, color: number): boolean {
|
private isColorValid(regions: Region[], region: Region, color: number): boolean {
|
||||||
for (const neighborId of region.neighbors) {
|
for (const neighborId of region.neighbors) {
|
||||||
if (regions[neighborId].color === color) {
|
if (regions[neighborId].color === color) {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<mat-card class="w-full max-w-[1920px] p-5">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ 'FOURIER.TITLE' | translate }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<app-information [algorithmInformation]="algoInformation"/>
|
||||||
|
|
||||||
|
<p class="mt-2.5">{{ 'FOURIER.DRAW_HINT' | translate }}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||||
|
<button mat-raised-button (click)="applyPreset(Preset.HEART)">
|
||||||
|
<mat-icon>favorite</mat-icon>{{ 'FOURIER.PRESET_HEART' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="applyPreset(Preset.STAR)">
|
||||||
|
<mat-icon>star</mat-icon>{{ 'FOURIER.PRESET_STAR' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="applyPreset(Preset.SQUARE)">
|
||||||
|
<mat-icon>crop_square</mat-icon>{{ 'FOURIER.PRESET_SQUARE' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="applyPreset(Preset.DOG)">
|
||||||
|
<mat-icon>pets</mat-icon>{{ 'FOURIER.PRESET_DOG' | translate }}
|
||||||
|
</button>
|
||||||
|
<button mat-raised-button (click)="toggleCircles()">
|
||||||
|
{{ (showCircles() ? 'FOURIER.HIDE_CIRCLES' : 'FOURIER.SHOW_CIRCLES') | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2.5 flex-wrap">
|
||||||
|
<app-slider-field label="FOURIER.CIRCLES" [min]="1" [max]="maxCircles()" discrete [(value)]="circleCount"/>
|
||||||
|
<app-slider-field label="FOURIER.SPEED" [min]="MIN_SPEED" [max]="MAX_SPEED" [step]="0.02" [(value)]="speed"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-metrics [metrics]="metrics()"/>
|
||||||
|
|
||||||
|
<div class="w-full max-w-[800px] mx-auto">
|
||||||
|
<canvas #fourierCanvas
|
||||||
|
class="touch-none cursor-crosshair max-w-full"
|
||||||
|
(pointerdown)="onPointerDown($event)"
|
||||||
|
(pointermove)="onPointerMove($event)"
|
||||||
|
(pointerup)="onPointerUp()"
|
||||||
|
(pointercancel)="onPointerUp()"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||||
|
import {provideZonelessChangeDetection} from '@angular/core';
|
||||||
|
import {provideNoopAnimations} from '@angular/platform-browser/animations';
|
||||||
|
import {provideTranslateService} from '@ngx-translate/core';
|
||||||
|
import {FourierComponent} from './fourier.component';
|
||||||
|
import {Preset, SAMPLE_COUNT} from './fourier.models';
|
||||||
|
|
||||||
|
describe('FourierComponent', () => {
|
||||||
|
let fixture: ComponentFixture<FourierComponent>;
|
||||||
|
let component: FourierComponent;
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
const originalGetContext = HTMLCanvasElement.prototype.getContext;
|
||||||
|
|
||||||
|
let fakeContext: Record<string, any>;
|
||||||
|
|
||||||
|
const runFrame = (now: number) => (component as any).frame(now);
|
||||||
|
const pointer = (x: number, y: number, isPrimary = true) => new PointerEvent('pointermove', {clientX: x, clientY: y, isPrimary});
|
||||||
|
const down = (x: number, y: number, isPrimary = true) => new PointerEvent('pointerdown', {clientX: x, clientY: y, isPrimary});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// jsdom has no 2D context.
|
||||||
|
fakeContext = {
|
||||||
|
clearRect: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(), arc: vi.fn(), closePath: vi.fn(),
|
||||||
|
strokeStyle: '', lineWidth: 1,
|
||||||
|
};
|
||||||
|
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: () => fakeContext});
|
||||||
|
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [FourierComponent],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
provideNoopAnimations(),
|
||||||
|
provideTranslateService({lang: 'en', fallbackLang: 'en'}),
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(FourierComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
canvas = component.canvasRef.nativeElement;
|
||||||
|
canvas.setPointerCapture = vi.fn();
|
||||||
|
canvas.getBoundingClientRect = () => ({left: 0, top: 0, width: 400, height: 400}) as DOMRect;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: originalGetContext});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with the heart preset', () => {
|
||||||
|
expect(component.terms().length).toBe(SAMPLE_COUNT);
|
||||||
|
expect(component.circleCount()).toBe(SAMPLE_COUNT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the terms when another preset is applied', () => {
|
||||||
|
const heartFirst = component.terms()[0].amplitude;
|
||||||
|
component.circleCount.set(5);
|
||||||
|
|
||||||
|
component.applyPreset(Preset.STAR);
|
||||||
|
|
||||||
|
expect(component.terms()[0].amplitude).not.toBeCloseTo(heartFirst);
|
||||||
|
expect(component.circleCount()).toBe(component.terms().length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits the used terms to the slider value', () => {
|
||||||
|
component.circleCount.set(3);
|
||||||
|
|
||||||
|
expect(component.usedTerms().length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds new terms from a drawn shape', () => {
|
||||||
|
component.applyPreset(Preset.SQUARE);
|
||||||
|
const before = component.terms();
|
||||||
|
|
||||||
|
component.onPointerDown(down(100, 100));
|
||||||
|
component.onPointerMove(pointer(300, 100));
|
||||||
|
component.onPointerMove(pointer(200, 300));
|
||||||
|
component.onPointerUp();
|
||||||
|
|
||||||
|
expect(component.terms()).not.toBe(before);
|
||||||
|
expect(component.terms().length).toBe(SAMPLE_COUNT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the shape when fewer than three points are drawn', () => {
|
||||||
|
const before = component.terms();
|
||||||
|
|
||||||
|
component.onPointerDown(down(100, 100));
|
||||||
|
component.onPointerMove(pointer(300, 100));
|
||||||
|
component.onPointerUp();
|
||||||
|
|
||||||
|
expect(component.terms()).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the shape when the stroke is a tap with jitter', () => {
|
||||||
|
const before = component.terms();
|
||||||
|
|
||||||
|
component.onPointerDown(down(100, 100));
|
||||||
|
component.onPointerMove(pointer(101, 100));
|
||||||
|
component.onPointerMove(pointer(100, 101));
|
||||||
|
component.onPointerUp();
|
||||||
|
|
||||||
|
expect(component.terms()).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a second touch while drawing', () => {
|
||||||
|
const before = component.terms();
|
||||||
|
|
||||||
|
component.onPointerDown(down(100, 100));
|
||||||
|
component.onPointerDown(down(300, 300, false));
|
||||||
|
component.onPointerMove(pointer(300, 300, false));
|
||||||
|
component.onPointerMove(pointer(300, 100));
|
||||||
|
component.onPointerMove(pointer(200, 300));
|
||||||
|
component.onPointerUp();
|
||||||
|
|
||||||
|
expect(component.terms()).not.toBe(before);
|
||||||
|
expect((component as any).drawnPoints.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws one circle per used term on each frame', () => {
|
||||||
|
component.circleCount.set(5);
|
||||||
|
|
||||||
|
runFrame(1);
|
||||||
|
runFrame(17);
|
||||||
|
|
||||||
|
expect(fakeContext['arc']).toHaveBeenCalledTimes(10);
|
||||||
|
expect(fakeContext['stroke']).toHaveBeenCalled();
|
||||||
|
expect((component as any).trace.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps time into 0..1 after a long frame gap', () => {
|
||||||
|
component.speed.set(0.5);
|
||||||
|
|
||||||
|
runFrame(1);
|
||||||
|
runFrame(10501);
|
||||||
|
|
||||||
|
expect((component as any).time).toBeCloseTo(0.25);
|
||||||
|
expect((component as any).trace.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops the animation loop on destroy', () => {
|
||||||
|
fixture.destroy();
|
||||||
|
|
||||||
|
expect(cancelAnimationFrame).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, signal, PLATFORM_ID, ViewChild} from '@angular/core';
|
||||||
|
import {isPlatformBrowser} from '@angular/common';
|
||||||
|
import {MatButton} from '@angular/material/button';
|
||||||
|
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||||
|
import {MatIcon} from '@angular/material/icon';
|
||||||
|
import {SliderFieldComponent} from '../../../shared/components/slider-field/slider-field';
|
||||||
|
import {TranslatePipe} from '@ngx-translate/core';
|
||||||
|
import {Information} from '../information/information';
|
||||||
|
import {AlgorithmInformation} from '../information/information.models';
|
||||||
|
import {SOURCE_PATHS} from '../information/source-paths';
|
||||||
|
import {UrlConstants} from '../../../constants/UrlConstants';
|
||||||
|
import {ThemeService} from '../../../service/theme.service';
|
||||||
|
import {FourierService} from './service/fourier.service';
|
||||||
|
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
|
||||||
|
import {FpsCounter} from '../../../shared/fps-counter';
|
||||||
|
import {
|
||||||
|
DEFAULT_SPEED, EpicycleColors, EpicycleTerm, MAX_CANVAS_PX, MAX_SPEED, MIN_DRAW_LENGTH, MIN_DRAW_POINTS, MIN_SPEED, Point, Preset, SAMPLE_COUNT, THEME_COLORS
|
||||||
|
} from './fourier.models';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-fourier',
|
||||||
|
imports: [
|
||||||
|
MatCard,
|
||||||
|
MatCardContent,
|
||||||
|
MatCardHeader,
|
||||||
|
MatCardTitle,
|
||||||
|
TranslatePipe,
|
||||||
|
Information,
|
||||||
|
MatButton,
|
||||||
|
MatIcon,
|
||||||
|
SliderFieldComponent,
|
||||||
|
MetricsComponent
|
||||||
|
],
|
||||||
|
templateUrl: './fourier.component.html'
|
||||||
|
})
|
||||||
|
export class FourierComponent implements AfterViewInit, OnDestroy {
|
||||||
|
private readonly fourierService = inject(FourierService);
|
||||||
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||||
|
private readonly themeService = inject(ThemeService);
|
||||||
|
private readonly ngZone = inject(NgZone);
|
||||||
|
|
||||||
|
@ViewChild('fourierCanvas', {static: true}) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
private ctx!: CanvasRenderingContext2D;
|
||||||
|
private animationFrameId = 0;
|
||||||
|
private lastFrameTime = 0;
|
||||||
|
private time = 0; // fraction of the period, 0..1
|
||||||
|
private trace: Point[] = []; // normalized tip positions of the current period
|
||||||
|
private drawnPoints: Point[] = []; // normalized pointer samples while drawing
|
||||||
|
private drawing = false;
|
||||||
|
|
||||||
|
algoInformation: AlgorithmInformation = {
|
||||||
|
title: 'FOURIER.EXPLANATION.TITLE',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
name: 'FOURIER.TITLE',
|
||||||
|
translateName: true,
|
||||||
|
description: 'FOURIER.EXPLANATION.EXPLANATION',
|
||||||
|
link: UrlConstants.FOURIER_SERIES_WIKI
|
||||||
|
}
|
||||||
|
],
|
||||||
|
disclaimer: 'FOURIER.EXPLANATION.DISCLAIMER',
|
||||||
|
disclaimerBottom: 'FOURIER.EXPLANATION.DISCLAIMER_BOTTOM',
|
||||||
|
disclaimerListEntry: [
|
||||||
|
'FOURIER.EXPLANATION.DISCLAIMER_1',
|
||||||
|
'FOURIER.EXPLANATION.DISCLAIMER_2',
|
||||||
|
'FOURIER.EXPLANATION.DISCLAIMER_3'
|
||||||
|
],
|
||||||
|
howItWorks: {intro: 'FOURIER.HOW_IT_WORKS.INTRO', items: ['FOURIER.HOW_IT_WORKS.ITEM_1', 'FOURIER.HOW_IT_WORKS.ITEM_2', 'FOURIER.HOW_IT_WORKS.ITEM_3', 'FOURIER.HOW_IT_WORKS.ITEM_4', 'FOURIER.HOW_IT_WORKS.ITEM_5']},
|
||||||
|
sources: SOURCE_PATHS.FOURIER
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly terms = signal<EpicycleTerm[]>([]);
|
||||||
|
readonly circleCount = signal(1);
|
||||||
|
readonly speed = signal(DEFAULT_SPEED);
|
||||||
|
readonly showCircles = signal(true);
|
||||||
|
readonly maxCircles = computed(() => Math.max(this.terms().length, 1));
|
||||||
|
readonly usedTerms = computed(() => this.terms().slice(0, this.circleCount()));
|
||||||
|
private readonly fpsCounter = new FpsCounter();
|
||||||
|
readonly metrics = computed<Metric[]>(() => [
|
||||||
|
{label: 'METRICS.FPS', value: this.fpsCounter.fps()},
|
||||||
|
{label: 'METRICS.SAMPLES', value: SAMPLE_COUNT}
|
||||||
|
]);
|
||||||
|
|
||||||
|
protected readonly Preset = Preset;
|
||||||
|
protected readonly MIN_SPEED = MIN_SPEED;
|
||||||
|
protected readonly MAX_SPEED = MAX_SPEED;
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (!this.isBrowser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
|
||||||
|
this.resizeCanvas();
|
||||||
|
this.applyPreset(Preset.HEART);
|
||||||
|
this.ngZone.runOutsideAngular(() => {
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.frame);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (!this.isBrowser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('window:resize')
|
||||||
|
resizeCanvas(): void {
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
canvas.width = canvas.height = Math.min(canvas.parentElement!.clientWidth, MAX_CANVAS_PX);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPreset(preset: Preset): void {
|
||||||
|
this.setShape(this.fourierService.presetPoints(preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCircles(): void {
|
||||||
|
this.showCircles.update(v => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerDown(event: PointerEvent): void {
|
||||||
|
if (!event.isPrimary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
this.canvasRef.nativeElement.setPointerCapture(event.pointerId);
|
||||||
|
this.drawing = true;
|
||||||
|
this.drawnPoints = [this.toNormalized(event)];
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerMove(event: PointerEvent): void {
|
||||||
|
if (this.drawing && event.isPrimary) {
|
||||||
|
this.drawnPoints.push(this.toNormalized(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp(): void {
|
||||||
|
this.drawing = false;
|
||||||
|
if (this.drawnPoints.length >= MIN_DRAW_POINTS && this.pathLength(this.drawnPoints) >= MIN_DRAW_LENGTH) {
|
||||||
|
this.setShape(this.drawnPoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pathLength(points: Point[]): number {
|
||||||
|
let length = 0;
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
length += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setShape(points: Point[]): void {
|
||||||
|
const samples = this.fourierService.resample(points, SAMPLE_COUNT);
|
||||||
|
this.terms.set(this.fourierService.dft(samples));
|
||||||
|
this.circleCount.set(this.terms().length);
|
||||||
|
this.time = 0;
|
||||||
|
this.trace = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private toNormalized(event: PointerEvent): Point {
|
||||||
|
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
|
||||||
|
const half = rect.width / 2;
|
||||||
|
return {x: (event.clientX - rect.left - half) / half, y: (event.clientY - rect.top - half) / half};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toCanvas(p: Point): Point {
|
||||||
|
const half = this.canvasRef.nativeElement.width / 2;
|
||||||
|
return {x: half + p.x * half, y: half + p.y * half};
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly frame = (now: number): void => {
|
||||||
|
this.fpsCounter.tick(now);
|
||||||
|
const dt = this.lastFrameTime ? (now - this.lastFrameTime) / 1000 : 0;
|
||||||
|
this.lastFrameTime = now;
|
||||||
|
const positions = this.advance(dt);
|
||||||
|
this.draw(positions);
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
private advance(dt: number): Point[] {
|
||||||
|
if (this.drawing || this.terms().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
this.time += this.speed() * dt;
|
||||||
|
if (this.time >= 1) {
|
||||||
|
this.time %= 1;
|
||||||
|
this.trace = [];
|
||||||
|
}
|
||||||
|
const used = this.usedTerms();
|
||||||
|
const positions = this.fourierService.epicyclePositions(used, used.length, this.time);
|
||||||
|
this.trace.push(positions.at(-1)!);
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw(positions: Point[]): void {
|
||||||
|
const size = this.canvasRef.nativeElement.width;
|
||||||
|
const colors = THEME_COLORS[this.themeService.theme()];
|
||||||
|
this.ctx.clearRect(0, 0, size, size);
|
||||||
|
this.ctx.lineWidth = 2;
|
||||||
|
if (this.drawing) {
|
||||||
|
this.strokePolyline(this.drawnPoints, colors.drawing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.strokePolyline(this.trace, colors.trace);
|
||||||
|
if (this.showCircles()) {
|
||||||
|
this.drawCircles(positions, colors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawCircles(positions: Point[], colors: EpicycleColors): void {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const half = this.canvasRef.nativeElement.width / 2;
|
||||||
|
const used = this.usedTerms();
|
||||||
|
for (let i = 0; i < positions.length - 1; i++) {
|
||||||
|
const center = this.toCanvas(positions[i]);
|
||||||
|
const next = this.toCanvas(positions[i + 1]);
|
||||||
|
ctx.strokeStyle = colors.circle;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(center.x, center.y, used[i].amplitude * half, 0, 2 * Math.PI);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = colors.path;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(center.x, center.y);
|
||||||
|
ctx.lineTo(next.x, next.y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private strokePolyline(points: Point[], color: string): void {
|
||||||
|
if (points.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
const c = this.toCanvas(p);
|
||||||
|
if (i === 0) {
|
||||||
|
ctx.moveTo(c.x, c.y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(c.x, c.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export interface Point { x: number; y: number; }
|
||||||
|
|
||||||
|
/** One rotating circle. frequency is signed (turns per period), phase in radians. */
|
||||||
|
export interface EpicycleTerm { frequency: number; amplitude: number; phase: number; }
|
||||||
|
|
||||||
|
export enum Preset { HEART = 0, STAR = 1, SQUARE = 2, DOG = 3 }
|
||||||
|
|
||||||
|
export const SAMPLE_COUNT = 256;
|
||||||
|
export const MIN_DRAW_POINTS = 3;
|
||||||
|
export const MIN_DRAW_LENGTH = 0.1; // normalized units; shorter strokes count as a tap
|
||||||
|
export const MIN_SPEED = 0.02; // periods per second
|
||||||
|
export const MAX_SPEED = 0.5;
|
||||||
|
export const DEFAULT_SPEED = 0.1;
|
||||||
|
export const MAX_CANVAS_PX = 800;
|
||||||
|
export const PRESET_RADIUS = 0.8; // presets stay inside the canvas
|
||||||
|
|
||||||
|
export interface EpicycleColors { circle: string; path: string; trace: string; drawing: string; }
|
||||||
|
|
||||||
|
export const THEME_COLORS: Record<'light' | 'dark', EpicycleColors> = {
|
||||||
|
light: { circle: 'rgba(0, 0, 0, 0.25)', path: '#0f4f8a', trace: '#d32f2f', drawing: '#555' },
|
||||||
|
dark: { circle: 'rgba(255, 255, 255, 0.3)', path: '#6dbcff', trace: '#ff7961', drawing: '#bbb' },
|
||||||
|
};
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {FourierService} from './fourier.service';
|
||||||
|
import {Point, Preset, SAMPLE_COUNT} from '../fourier.models';
|
||||||
|
|
||||||
|
describe('FourierService', () => {
|
||||||
|
const service = new FourierService();
|
||||||
|
|
||||||
|
it('resamples a polygon to equally spaced points', () => {
|
||||||
|
const square: Point[] = [{x: 0, y: 0}, {x: 1, y: 0}, {x: 1, y: 1}, {x: 0, y: 1}];
|
||||||
|
|
||||||
|
const samples = service.resample(square, 8);
|
||||||
|
|
||||||
|
expect(samples.length).toBe(8);
|
||||||
|
samples.forEach((p, i) => {
|
||||||
|
const next = samples[(i + 1) % 8];
|
||||||
|
expect(Math.hypot(next.x - p.x, next.y - p.y)).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resamples a single point to copies without NaN', () => {
|
||||||
|
const samples = service.resample([{x: 0.3, y: -0.2}], 5);
|
||||||
|
|
||||||
|
expect(samples).toEqual(Array(5).fill({x: 0.3, y: -0.2}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a unit circle into one circle of radius 1', () => {
|
||||||
|
const circle = Array.from({length: 64}, (_, n) => ({x: Math.cos(2 * Math.PI * n / 64), y: Math.sin(2 * Math.PI * n / 64)}));
|
||||||
|
|
||||||
|
const terms = service.dft(circle);
|
||||||
|
|
||||||
|
expect(terms[0].frequency).toBe(1);
|
||||||
|
expect(terms[0].amplitude).toBeCloseTo(1);
|
||||||
|
terms.slice(1).forEach(t => expect(t.amplitude).toBeLessThan(1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts terms by amplitude descending', () => {
|
||||||
|
const terms = service.dft(service.resample(service.presetPoints(Preset.STAR), 64));
|
||||||
|
|
||||||
|
for (let i = 0; i < terms.length - 1; i++) {
|
||||||
|
expect(terms[i].amplitude).toBeGreaterThanOrEqual(terms[i + 1].amplitude);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconstructs the input points from all terms', () => {
|
||||||
|
const samples = service.resample(service.presetPoints(Preset.SQUARE), 32);
|
||||||
|
const terms = service.dft(samples);
|
||||||
|
|
||||||
|
for (const n of [0, 7, 19]) {
|
||||||
|
const tip = service.epicyclePositions(terms, terms.length, n / 32).at(-1)!;
|
||||||
|
expect(tip.x).toBeCloseTo(samples[n].x, 6);
|
||||||
|
expect(tip.y).toBeCloseTo(samples[n].y, 6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates to the requested circle count', () => {
|
||||||
|
const terms = service.dft(service.resample(service.presetPoints(Preset.STAR), 32));
|
||||||
|
|
||||||
|
expect(service.epicyclePositions(terms, 3, 0.25).length).toBe(4);
|
||||||
|
expect(service.epicyclePositions(terms, 0, 0)).toEqual([{x: 0, y: 0}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([Preset.HEART, Preset.STAR, Preset.SQUARE, Preset.DOG])('keeps preset %s inside the canvas', preset => {
|
||||||
|
const points = service.presetPoints(preset);
|
||||||
|
|
||||||
|
expect(points.length).toBeGreaterThanOrEqual(3);
|
||||||
|
points.forEach(p => {
|
||||||
|
expect(Math.abs(p.x)).toBeLessThanOrEqual(1);
|
||||||
|
expect(Math.abs(p.y)).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mirrors the dog preset left-right', () => {
|
||||||
|
const points = service.presetPoints(Preset.DOG);
|
||||||
|
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
expect(points.some(q => Math.abs(q.x + p.x) < 1e-9 && Math.abs(q.y - p.y) < 1e-9)).toBe(true);
|
||||||
|
const next = points[(i + 1) % points.length];
|
||||||
|
expect(Math.hypot(next.x - p.x, next.y - p.y)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts the corners of a square with one chaikin pass', () => {
|
||||||
|
const square = service.presetPoints(Preset.SQUARE);
|
||||||
|
const r = square[2].x;
|
||||||
|
|
||||||
|
const cut = service.chaikin(square, 1);
|
||||||
|
|
||||||
|
expect(cut.length).toBe(8);
|
||||||
|
cut.forEach(p => {
|
||||||
|
expect(Math.abs(p.x)).toBeLessThanOrEqual(r);
|
||||||
|
expect(Math.abs(p.y)).toBeLessThanOrEqual(r);
|
||||||
|
expect(Math.abs(p.x) === r && Math.abs(p.y) === r).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('samples the heart preset SAMPLE_COUNT times', () => {
|
||||||
|
expect(service.presetPoints(Preset.HEART).length).toBe(SAMPLE_COUNT);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user