Compare commits

..
43 Commits
Author SHA1 Message Date
lobo 9c55298ab7 fix(cloth-icm): keep drop cloth free of intersections
Build, Test & Push Frontend / quality-check (push) Successful in 2m43s
Build, Test & Push Frontend / docker (push) Successful in 1m5s
The drop scene used the tangled-start settings, so layers stayed
intersecting. It now has its own grid (35x35, gap 0.035) and
re-checks proximity after 5 of 10 substeps.
After landing, ICM on drops from ~260 to ~1 hit per frame.
2026-10-30 15:41:12 +01:00
lobo ba82df7694 fix(cloth-icm): reuse contour mesh so contours draw
Contour lines were disposed and rebuilt when their shape changed,
so the new shader never finished compiling before removal.
Keep one mesh and set its geometry each frame.
Hide it when there are no contours.
Draw contours green and culled BVH boxes cyan for contrast.
2026-10-30 14:12:22 +01:00
lobo b1607a4b00 feat(algorithms): add cloth-icm self-collision page
Add a CPU cloth self-collision demo based on Volino and
Magnenat-Thalmann 2006 (intersection contour minimization).
It uses an 18-DOP BVH with normal-cone culling. The page has
tangled and drop scenes and a debug panel.
2026-10-30 13:14:24 +01:00
lobo 05dc554d79 test(algorithms): load simulations per test to fix flaky specs
Build, Test & Push Frontend / quality-check (push) Successful in 2m19s
Build, Test & Push Frontend / docker (push) Successful in 55s
Shared gpu-state.ts keeps the @babylonjs/core mock of the first spec
that loads it. With isolate: false, other specs got that stale mock.
Call vi.resetModules() and import the class in each test instead.
2026-10-23 15:23:14 +02:00
lobo 58b14a5b64 feat(algorithms): add voronoi and delaunay demo
Build, Test & Push Frontend / quality-check (push) Successful in 2m25s
Build, Test & Push Frontend / docker (push) Successful in 56s
Delaunay uses Bowyer-Watson with an exact in-circle test. Voronoi
cells are its dual, clipped to the canvas. Click adds a point, drag
moves it; layer toggles and an optional step mode. Max 400 points.
ROADMAP: tick Voronoi, drop the Lenia zoom item.
2026-10-23 15:03:50 +02:00
lobo 8db70fdd72 fix(algorithms): read params at the start of each frame
Params were pushed after the frame. Now each strategy reads them
at frame start, like lenia. Fixes gray-scott seeding a new preset
with old feed/kill, a possible pendulum WebGPU NaN after reset,
and an empty first frame on physarum/boids GPU.
2026-10-23 14:24:32 +02:00
lobo d298108516 feat(fluid): add Stable Fluids demo on float textures
Velocity runs on a 128x128 grid and dye on 512x512.
Walls are free-slip: shaders clamp coordinates to the field.
Drag pushes the fluid and adds dye. Idle time adds random splats.
Tick the fluid item in ROADMAP.md.
2026-10-23 14:09:42 +02:00
lobo 267d9ab8eb fix(lenia): read params at the start of each frame
A preset switch set params after the frame, so the first frame used
the old mu/sigma. This broke Scutium and made Gyrorbium unstable.
Now LeniaSimulation reads params at each frame start (readParams).
Add a ROADMAP item for a display zoom (Bicaudatus tails).
2026-10-23 13:43:33 +02:00
lobo 2df5ef2089 fix(slider-field): use fixed column widths so rows align
Label, slider and number field now have fixed widths, so stacked
rows line up. Below 640 px the label sits above the slider and field.
2026-10-23 13:34:55 +02:00
lobo 3018c3f1b5 feat(slider-field): add number input to all algorithm sliders
Add app-slider-field: a mat-slider with a number field. Typed values
apply on Enter or blur, clamped and snapped to the step.
Pendulum uses it. Fractal keeps ngx-slider (log zoom) plus a field.
Drop the ": value" suffix from slider labels.
2026-10-23 13:29:36 +02:00
lobo 008f1254f8 feat(lenia): add lenia demo and shared gpu-state module
Move gpu-state.ts out of gray-scott so gray-scott and lenia share it.
Fix gray-scott: skip the texture swap until the step shader is ready,
otherwise the seed is lost.
Info panel: support routerLink, so GoL and Lenia link to each other.
2026-10-23 11:11:12 +02:00
lobo 4432e38cd2 feat(ant-colony): add ant colony optimization demo for tsp
Ant System on the travelling salesman problem, run on the CPU.
Click the canvas to add cities while the colony runs.
Show the nearest-neighbor tour as a baseline. Tick ROADMAP.
2026-10-23 10:20:06 +02:00
lobo 26601bef46 fix(physarum): paint only while the pointer is pressed
Ignore pointer moves after release.
2026-10-23 10:01:03 +02:00
lobo 0013dc175a feat(gray-scott): add reaction-diffusion demo
Add a Gray-Scott page with a shader ping-pong on float textures,
five presets and pointer painting.
Tick the Gray-Scott item in ROADMAP.md.
2026-10-23 10:01:03 +02:00
lobo 24e7b42896 feat(physarum): add slime mold network demo
Add a Physarum route with a GPU trail map and a CPU fallback.
Tick the Physarum item in ROADMAP.md.
2026-10-23 08:42:02 +02:00
lobo 108cea0491 feat(boids): add boids and particle life demo
Add linkLabel to AlgorithmEntry. The info panel shows it instead of
"Wikipedia" when set, for links that are not Wikipedia.
Tick the boids item in ROADMAP.md and add a Boids 3D item.
2026-10-23 07:46:09 +02:00
lobo 2224774e00 fix(four-color): stop solver freeze on large maps
Build, Test & Push Frontend / quality-check (push) Successful in 2m20s
Build, Test & Push Frontend / docker (push) Successful in 49s
Plain backtracking froze the tab on big maps. Pick the most
constrained region first (DSATUR), and stop after 100,000 attempts.
Measured on 52,000 maps (38x38): max 48 ms, about 1 in 2,900 give up.
2026-10-21 15:07:34 +02:00
lobo 07e0fc1192 fix(fractal): keep non-escaping points black
Points that never escape kept the last loop index as iter, so they
never reached the black color. Start iter at maxIterations and set it
only when a point escapes. Update the ITEM_4 text (en, de) to match.
2026-10-21 14:43:52 +02:00
lobo 833e1ec8dc feat(algorithms): add live metrics to algorithm pages
Each algorithm page shows its numbers with app-metrics.
FpsCounter publishes a rate every 500 ms, not every frame.
Fractal 2D reset now also resets the zoom slider.
2026-10-21 14:35:15 +02:00
lobo 4b19245c6c feat(algorithms): add how-it-works and source links
Add idea, complexity, data structures, GPU use to 10 algorithm pages.
Link logic files on Gitea; a spec fails on a missing path.
Fix wrong texts: cloth phases, pendulum dt, fractal lighting, typos.
2026-10-21 11:51:15 +02:00
lobo 0857ce482b feat(projects): add decisions and result to project dialog
Build, Test & Push Frontend / quality-check (push) Successful in 2m18s
Build, Test & Push Frontend / docker (push) Successful in 50s
Add optional Decisions and Result blocks to the project dialog.
Fill them for ToBePlayed, Tribble, Claude-in-a-Box and Playground.
Remove Portainer and the Google Drive backup claims from Tribble.
2026-10-21 10:39:30 +02:00
lobo 1cff9e6eb2 fix(algorithms): render sliders only after first browser render
ngx-slider throws during prerender (no layout APIs on the server).
Its ngOnDestroy then calls disconnect() on null, so about 1 in 10
prerender builds failed. Gate fractal and pendulum sliders on a
signal set by afterNextRender.
2026-10-21 08:34:39 +02:00
lobo 82228e9141 docs(projects): update claude-in-a-box project text and diagram
The box now runs an agent pipeline, assist mode and evals.
The old card and diagram only described rule loading and the commit hook.
2026-10-21 08:27:27 +02:00
lobo 910822028b feat(projects): add playground architecture diagram and notes
Update the Playground entry on the projects page: diagram,
prerender, privacy and operations bullets, one challenge,
one learning, and the Traefik, nginx and Tailscale technologies.
2026-10-21 08:04:25 +02:00
lobo e13db8c1de docs(about): shorten role title and describe MIDO tool
Build, Test & Push Frontend / quality-check (push) Successful in 2m22s
Build, Test & Push Frontend / docker (push) Successful in 1m8s
Keep the role line short. Give the MIDO highlight concrete scope
in de and en.
2026-10-20 16:01:35 +02:00
lobo ee9caf71cb chore(privacy): disable nginx access log, update hosting text
Privacy page says no visitor IPs are stored.
Hosting text now names Hostinger and its DPA.
Remove the TODO placeholders in de and en.
2026-10-20 15:56:12 +02:00
lobo 54b93055ba feat(app): add prerender, self-hosted fonts and privacy page
Fonts come from npm, not Google, so no third-party font requests.
Pages are prerendered at build time; browser-only code is guarded.
nginx serves per-route HTML, a real 404 and gzip.
2026-10-20 15:43:31 +02:00
lobo 57f861510e feat(fourier): add dog head preset
Build, Test & Push Frontend / quality-check (push) Successful in 1m21s
Build, Test & Push Frontend / docker (push) Successful in 1m29s
Build the dog outline from one mirrored right half.
Smooth it with Chaikin corner cutting (3 passes).
Also add a ROADMAP note for the circles slider thumb.
2026-10-14 16:29:17 +02:00
lobo 52375e5ab1 feat(fourier): add epicycles algorithm demo
Build, Test & Push Frontend / quality-check (push) Successful in 1m25s
Build, Test & Push Frontend / docker (push) Successful in 53s
Draw a shape or pick a preset, then rebuild it with a direct DFT.
Each frequency becomes one circle. Circles are sorted by radius, and
the circles slider keeps the biggest N. Also tick the ROADMAP item and
add the FOURIER SEO key.
2026-10-14 16:05:54 +02:00
lobo dc23023b32 docs: add ROADMAP.md 2026-10-14 15:40:20 +02:00
lobo 718bc28bd0 feat(pathfinding): add A* vs Dijkstra compare mode
Show A* and Dijkstra side by side on the same grid with visited count and path length. Also clamp typed grid sizes above the max instead of crashing.
2026-10-14 15:40:20 +02:00
lobo ce7db0f376 fix(pathfinding): use real cost in A* instead of greedy heuristic
updateNeighborNode overwrote gScore with the heuristic, so A* behaved like greedy best-first search and found longer paths than Dijkstra.
2026-10-14 15:40:20 +02:00
lobo 331d653ae6 docs(i18n): fix german wording in project and about texts
Build, Test & Push Frontend / quality-check (push) Successful in 1m15s
Build, Test & Push Frontend / docker (push) Successful in 50s
Fix wrong meanings in the diploma text and the AdGuard line.
Make other sentences natural German.
2026-10-14 15:32:13 +02:00
lobo 8681508cb6 feat(projects): rewrite project texts with verified facts
Build, Test & Push Frontend / quality-check (push) Successful in 1m18s
Build, Test & Push Frontend / docker (push) Successful in 50s
Old copy was generic and did not match the code. Texts now describe
the real features: PlayStation sync, file imports, tests, security.
Add per-game itch.io links, new chips, and a test that checks every
About project key exists in en and de.
2026-10-14 11:03:54 +02:00
lobo 106d39453d fix(about): show original company logos again
Remove the logo invert added in 07a9b8b and its test.
The inverted logos were not wanted, so the originals show again.
2026-10-14 09:04:46 +02:00
lobo 07a9b8bc11 fix(site): remove canvas frame, invert logos, new footer text
The canvas had a 1px frame. The company logos are opaque white, so
invert them to match the dark tiles. Core fetches the Auth public key
(JWKS), so the To Be Played diagram now shows that arrow.
2026-10-14 08:49:00 +02:00
lobo 93e9c59436 feat(projects): add architecture diagrams, align project texts
Add diagrams to three projects. A tap opens them full size.
Align en/de texts: no versions, Gitea not GitHub, Portainer, home
server + VPS. Make To Be Played the featured project.
Remove the Hostinger logo image.
2026-10-14 08:18:18 +02:00
lobo 449e2d430a chore(contact): use info@andreas-dahm.eu as contact email
Build, Test & Push Frontend / quality-check (push) Successful in 1m15s
Build, Test & Push Frontend / docker (push) Successful in 49s
The old private address is no longer used. Update the imprint
page, the mail button and the README.
2026-10-14 07:53:04 +02:00
lobo 034e397864 refactor(algorithms): move algorithm logic out of components
Build, Test & Push Frontend / quality-check (push) Successful in 1m25s
Build, Test & Push Frontend / docker (push) Successful in 51s
Move logic from 5 components into services and pure modules, with a
shared animator for pathfinding and labyrinth. Add specs for them and
for pendulum and cloth physics.
Fix Game of Life crash: clamp rows and cols to the max.
Fix random pathfinding on 2 columns: in bounds, start not equal end.
2026-10-13 11:49:17 +02:00
lobo dc9f09bede refactor(components): use OnPush and signals in components
Angular 22 defaults to OnPush, so Eager is removed.
Template state is now signals, so sorting needs no detectChanges.
Game of Life execution time now updates live.
Remove the unused ImageDialogComponent and turn the OnPush lint rule
back on.
2026-10-13 11:10:04 +02:00
lobo 2abc7c31e2 perf(assets): convert images to webp and check asset paths
Convert 26 PNG/JPG assets to WebP: max 1600 px, q80, diagrams lossless.
src/assets shrinks from 8.3 MB to 4.2 MB.
Use me.jpg for og:image and twitter:image; some crawlers skip WebP.
Add scripts/check-assets.mjs to lint, so a wrong asset path fails lint,
not only at runtime.
2026-10-13 09:13:58 +02:00
lobo 4e59770e53 build(test): run unit tests with vitest and jsdom
Karma needed headless Chrome. Vitest with jsdom runs with no browser.
Lighthouse CI now gets Chrome from @puppeteer/browsers.
Two CSS font tests are dropped: jsdom does not apply real CSS.
2026-10-13 09:02:45 +02:00
lobo dd7c9f6e3d fix(projects): register swiper custom elements in project dialog
The slider never ran because Swiper custom elements were not registered.
Use the bundle build so the navigation and pagination modules are included.
Add a spec that checks the registration.
2026-10-13 08:18:22 +02:00
295 changed files with 19164 additions and 3757 deletions

No files matched your search

+1
View File
@@ -46,6 +46,7 @@ Dockerfile text eol=lf
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.pdf binary
*.zip binary
*.tar binary
+8 -6
View File
@@ -26,6 +26,7 @@ jobs:
check-latest: true
cache: 'npm'
# Shared libraries for the Chrome that Lighthouse CI runs
- name: Install Linux Libs
run: |
sudo apt-get update
@@ -57,21 +58,22 @@ jobs:
- name: i18n Key Check
run: npm run i18n:check
# 3. Unit Tests (Logic) — browser/launcher configured in karma.conf.js
# 3. Unit Tests (Vitest + jsdom, no browser)
- name: Unit Tests
run: npx ng test --watch=false
run: npm test
# 4. Build Production (necessary for lighthouse)
- name: Build Production
run: npx ng build --configuration production
- name: Prerender Check
run: npm run prerender:check
# 5. Lighthouse Audit (Performance & SEO)
# Puppeteer is a devDependency, so `npm ci` already installed it and
# downloaded Chrome. executablePath() is async in Puppeteer v25, so await it.
- name: Lighthouse CI
run: |
CHROME_PATH=$(node -e 'require("puppeteer").executablePath().then(p => console.log(p))')
export CHROME_PATH=$CHROME_PATH
CHROME_PATH=$(npx @puppeteer/browsers install chrome@stable --path "$RUNNER_TEMP/chrome" --format '{{path}}')
export CHROME_PATH
npx lhci autorun
# ------------------------------------------------------------------
-2
View File
@@ -1,8 +1,6 @@
# Build
FROM node:22-alpine AS build
WORKDIR /app
# The production build never runs tests, so skip Puppeteer's Chrome download.
ENV PUPPETEER_SKIP_DOWNLOAD=1
COPY package*.json ./
RUN npm ci
COPY . .
+1
View File
@@ -39,6 +39,7 @@ To run the unit tests:
```bash
ng test
```
`npm test` runs once (Vitest + jsdom). `ng test` watches in a terminal; use `--watch=false` to run once.
### Linting
+7 -6
View File
@@ -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**,
deployed at:
👉 [https://app.andreas-dahm.eu](https://app.andreas-dahm.eu)
👉 [https://andreas-dahm.eu](https://andreas-dahm.eu)
---
@@ -87,9 +87,10 @@ Then open http://localhost:8080
| `npm start` | Dev server |
| `npm run build` | Production build |
| `npm run watch` | Rebuild on change (development configuration) |
| `npm test` | Unit tests (Karma + Jasmine, headless Chrome) |
| `npm test` | Unit tests (Vitest + jsdom, no browser needed) |
| `npm run lint` | ESLint over `src/**/*.ts` and `src/**/*.html` |
| `npm run i18n:check` | Verifies `en.json` and `de.json` share the exact same keys |
| `npm run assets:check` | Verifies every asset path referenced in `src/` exists (also runs in `npm run lint`) |
---
@@ -102,9 +103,9 @@ Triggered on every push to `main` and on pull requests targeting `main`.
**Job 1 — `quality-check`** (runs on both pushes and PRs):
1. Lint & type check — `npm run lint`
1. Lint & type check — `npm run lint` (includes `npm run assets:check`)
2. i18n key sync — `npm run i18n:check`
3. Unit tests — `npx ng test --watch=false`
3. Unit tests — `npm test`
4. Production build — `npx ng build --configuration production`
5. Lighthouse audit — `npx lhci autorun` (performance & SEO)
@@ -137,12 +138,12 @@ docker compose pull
docker compose up -d
```
✅ Live site at https://app.andreas-dahm.eu
✅ Live site at https://andreas-dahm.eu
---
## 🪄 Maintainer
Andreas Dahm
📧 andreas.dahm@gmail.com
📧 info@andreas-dahm.eu
🌐 https://andreas-dahm.eu
+35
View File
@@ -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)
+15 -21
View File
@@ -41,6 +41,10 @@
}
],
"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/styles.scss",
"node_modules/swiper/swiper-bundle.css"
@@ -60,7 +64,17 @@
"maximumError": "8kB"
}
],
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": false
},
"fonts": true
},
"outputHashing": "all",
"outputMode": "static",
"server": "src/main.server.ts",
"serviceWorker": "ngsw-config.json"
},
"development": {
@@ -87,27 +101,7 @@
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"karmaConfig": "karma.conf.js",
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*",
"input": "src/assets",
"output": "/assets"
}
],
"styles": [
"src/tailwind.css",
"src/styles.scss"
]
}
"builder": "@angular/build:unit-test"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
-2
View File
@@ -18,8 +18,6 @@ module.exports = defineConfig([
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/prefer-for-of": "off",
// ng update set ChangeDetectionStrategy.Eager everywhere; OnPush is a separate refactor.
"@angular-eslint/prefer-on-push-component-change-detection": "off",
"@angular-eslint/directive-selector": [
"error",
{
-43
View File
@@ -1,43 +0,0 @@
const os = require('os');
const path = require('path');
const { computeExecutablePath, Browser } = require('@puppeteer/browsers');
const puppeteer = require('puppeteer');
// Resolve the Chrome that Puppeteer downloaded so tests run without a
// system-installed browser, both locally and in CI containers.
// Puppeteer v25 made executablePath() async, which a sync karma.conf cannot
// await — so we compute the path synchronously via @puppeteer/browsers instead.
const buildId = puppeteer.PUPPETEER_REVISIONS.chrome;
const cacheDir = process.env.PUPPETEER_CACHE_DIR || path.join(os.homedir(), '.cache', 'puppeteer');
process.env.CHROME_BIN = computeExecutablePath({ browser: Browser.CHROME, buildId, cacheDir });
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
],
jasmineHtmlReporter: {
suppressAll: true,
},
coverageReporter: {
dir: path.join(__dirname, 'coverage', 'playground-frontend'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }],
},
reporters: ['progress', 'kjhtml'],
browsers: ['ChromeHeadlessNoSandbox'],
customLaunchers: {
ChromeHeadlessNoSandbox: {
base: 'ChromeHeadless',
// CI containers run as root with no sandbox; /dev/shm is often too small.
flags: ['--no-sandbox', '--headless', '--disable-gpu', '--disable-dev-shm-usage'],
},
},
restartOnFileChange: true,
});
};
+1
View File
@@ -2,6 +2,7 @@
"ci": {
"collect": {
"numberOfRuns": 1,
"autodiscoverUrlBlocklist": ["/index.csr.html"],
"staticDistDir": "./dist/playground-frontend/browser",
"settings": {
"chromeFlags": "--no-sandbox --headless --disable-gpu --disable-dev-shm-usage"
+22 -4
View File
@@ -3,6 +3,14 @@ server {
server_name _;
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
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
@@ -41,6 +49,12 @@ server {
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
location ^~ /assets/ {
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
@@ -49,13 +63,17 @@ server {
# Also no cache for other html site
location ~* \.html$ {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
try_files $uri =404;
}
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
View File
@@ -1,6 +1,6 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"index": "/index.csr.html",
"assetGroups": [
{
"name": "app",
@@ -9,6 +9,7 @@
"files": [
"/favicon.ico",
"/index.html",
"/index.csr.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
@@ -22,6 +23,7 @@
"resources": {
"files": [
"/assets/**",
"/media/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
+786 -1737
View File
File diff suppressed because it is too large. Load diff
+12 -12
View File
@@ -6,9 +6,11 @@
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"lint": "ng lint",
"i18n:check": "node scripts/check-i18n.mjs"
"test": "ng test --watch=false",
"lint": "ng lint && npm run assets:check",
"i18n:check": "node scripts/check-i18n.mjs",
"assets:check": "node scripts/check-assets.mjs",
"prerender:check": "node scripts/check-prerender.mjs"
},
"private": true,
"dependencies": {
@@ -21,11 +23,15 @@
"@angular/forms": "~22.2.2",
"@angular/material": "~22.2.2",
"@angular/platform-browser": "~22.2.2",
"@angular/platform-server": "~22.2.2",
"@angular/router": "~22.2.2",
"@angular/service-worker": "~22.2.2",
"@angular/ssr": "~22.2.2",
"@babylonjs/core": "^9.30.0",
"@fontsource/roboto": "^5.3.0",
"@ngx-translate/core": "^18.0.0",
"@ngx-translate/http-loader": "^18.0.0",
"material-icons": "^1.13.14",
"rxjs": "~7.8.2",
"swiper": "^14.3.0",
"tslib": "~2.8.1"
@@ -38,20 +44,14 @@
"@lhci/cli": "^0.15.1",
"@puppeteer/browsers": "3.2.4",
"@tailwindcss/postcss": "^4.3.3",
"@types/jasmine": "~7.0.0",
"@webgpu/types": "^0.1.74",
"angular-eslint": "22.6.0",
"eslint": "^10.12.0",
"jasmine-core": "^7.0.2",
"karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0",
"karma-coverage": "^2.2.1",
"karma-jasmine": "^5.1.0",
"karma-jasmine-html-reporter": "^2.2.0",
"puppeteer": "^25.13.0",
"jsdom": "^30.1.2",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.71.1"
"typescript-eslint": "^8.71.1",
"vitest": "^5.0.4"
},
"overrides": {
"tmp": "^0.2.3",
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
// Verifies that every asset path in AssetsConstants.ts and index.html exists.
// A wrong path builds fine and only 404s at runtime.
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join, dirname, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
const srcDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src');
const EXTENSIONS = new Set(['.ts', '.html', '.scss', '.json']);
function* walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(path);
} else if (EXTENSIONS.has(extname(path)) && !path.endsWith('.spec.ts')) {
yield path;
}
}
}
const missing = [];
for (const file of walk(srcDir)) {
const content = readFileSync(file, 'utf8');
for (const [, path] of content.matchAll(/\b(assets\/[\w./-]+)/g)) {
if (!existsSync(join(srcDir, path))) {
missing.push(`${file}: ${path}`);
}
}
}
if (missing.length > 0) {
console.error(`Missing assets:\n${missing.join('\n')}`);
process.exit(1);
}
console.log('All referenced assets exist.');
+78
View File
@@ -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.');
+8
View File
@@ -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))],
});
+2 -2
View File
@@ -3,7 +3,7 @@ import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes';
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 {provideTranslateService} from '@ngx-translate/core';
import {provideTranslateHttpLoader} from '@ngx-translate/http-loader';
@@ -25,7 +25,7 @@ export const appConfig: ApplicationConfig = {
anchorScrolling: 'enabled'
})),
provideAnimations(),
provideHttpClient(withXhr()),
provideHttpClient(withFetch()),
provideTranslateService({
lang: INITIAL_LANG,
fallbackLang: 'en',
+21
View File
@@ -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);
});
});
+12
View File
@@ -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 },
];
+10
View File
@@ -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.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.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.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.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.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.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: '**', loadComponent: () => import('./pages/not-found/not-found.component').then(m => m.NotFoundComponent), data: { seo: 'NOT_FOUND' } },
];
+29 -29
View File
@@ -1,61 +1,61 @@
export class AssetsConstants {
static readonly ME = '/assets/me.webp';
static readonly LOGO = '/assets/logos/logo-wolf.png';
static readonly LOGO = '/assets/logos/logo-wolf.webp';
static readonly FLAG_DE = '/assets/flags/de.svg';
static readonly FLAG_EN = '/assets/flags/gb.svg';
//logos
static readonly CHAMAELEON_LOGO = '/assets/logos/logo_chamaeleon.svg';
static readonly TH_BINGEN_LOGO = '/assets/logos/bingen-logo-white.svg';
static readonly ASSYST_LOGO = '/assets/logos/assyst_gmbh_logo.jpg';
static readonly COLORDIGITAL_LOGO = '/assets/logos/dmixcloud_logo.jpg';
static readonly TERAPORT_LOGO = '/assets/logos/teraport_gmbh_logo.jpg';
static readonly ASSYST_LOGO = '/assets/logos/assyst_gmbh_logo.webp';
static readonly COLORDIGITAL_LOGO = '/assets/logos/dmixcloud_logo.webp';
static readonly TERAPORT_LOGO = '/assets/logos/teraport_gmbh_logo.webp';
static readonly DIPLOMA = '/assets/projects/diploma/Dahm2010-Diplomarbeit.pdf';
//project images
static readonly PLAYGROUND_IMAGES = [
'/assets/projects/playground/1.png'
'/assets/projects/playground/1.webp'
];
static readonly EL_MUCHO_IMAGES = [
'/assets/projects/el-mucho/1.jpg',
'/assets/projects/el-mucho/2.jpg',
'/assets/projects/el-mucho/3.jpg',
'/assets/projects/el-mucho/4.jpg'
'/assets/projects/el-mucho/1.webp',
'/assets/projects/el-mucho/2.webp',
'/assets/projects/el-mucho/3.webp',
'/assets/projects/el-mucho/4.webp'
];
static readonly GAME_JAMS_IMAGES = [
'/assets/projects/game-jams/1.png',
'/assets/projects/game-jams/2.png',
'/assets/projects/game-jams/3.png'
'/assets/projects/game-jams/1.webp',
'/assets/projects/game-jams/2.webp',
'/assets/projects/game-jams/3.webp'
];
static readonly DIPLOMA_IMAGES = [
'/assets/projects/diploma/1.jpg',
'/assets/projects/diploma/2.jpg',
'/assets/projects/diploma/3.jpg',
'/assets/projects/diploma/4.jpg',
'/assets/projects/diploma/5.jpg',
'/assets/projects/diploma/6.jpg'
'/assets/projects/diploma/1.webp',
'/assets/projects/diploma/2.webp',
'/assets/projects/diploma/3.webp',
'/assets/projects/diploma/4.webp',
'/assets/projects/diploma/5.webp',
'/assets/projects/diploma/6.webp'
];
static readonly CLAUDE_BOX_IMAGES = [
'/assets/projects/claude-in-a-box/1.svg'
];
static readonly CLAUDE_BOX_DIAGRAM = '/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 = [
'/assets/projects/tribble-the-homeserver/1.png',
'/assets/projects/tribble-the-homeserver/2.png',
'/assets/projects/tribble-the-homeserver/3.png',
'/assets/projects/tribble-the-homeserver/4.png'
'/assets/projects/tribble-the-homeserver/2.webp',
'/assets/projects/tribble-the-homeserver/3.webp',
'/assets/projects/tribble-the-homeserver/4.webp'
];
static readonly TBP_IMAGES = [
'/assets/projects/to-be-played/1.png',
'/assets/projects/to-be-played/2.png',
'/assets/projects/to-be-played/3.png',
'/assets/projects/to-be-played/4.png'
'/assets/projects/to-be-played/1.webp',
'/assets/projects/to-be-played/2.webp',
'/assets/projects/to-be-played/3.webp',
'/assets/projects/to-be-played/4.webp'
];
}
+50
View File
@@ -55,16 +55,66 @@
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 = {
PATH: 'algorithms/four_color',
LINK: '/algorithms/four_color',
};
static readonly FOURIER = {
PATH: 'algorithms/fourier',
LINK: '/algorithms/fourier',
};
static readonly IMPRINT = {
PATH: 'imprint',
LINK: '/imprint',
};
static readonly PRIVACY = {
PATH: 'privacy',
LINK: '/privacy',
};
static readonly STOPWATCH = {
PATH: 'stopwatch',
LINK: '/stopwatch',
+19
View File
@@ -21,9 +21,28 @@
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 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 DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur'
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 PLAYGROUND_SOURCE_BASE = `${UrlConstants.GITEA}/Lobo_Corp/playground-frontend/src/branch/main/`
}
+8 -1
View File
@@ -5,10 +5,17 @@
</main>
<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 />
<small>
{{ 'APP.AI_NOTICE' | translate }}
<a [routerLink]="['/projects']" [queryParams]="{ project: 'claude-in-a-box' }">{{ 'APP.AI_NOTICE_LINK' | translate }}</a>
</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>
+49
View File
@@ -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']);
});
});
+3 -2
View File
@@ -1,20 +1,21 @@
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { Component, inject } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import {TopbarComponent} from '../topbar/topbar.component';
import {TranslatePipe} from '@ngx-translate/core';
import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component';
import {SeoService} from '../../service/seo.service';
import {RouterConstants} from '../../constants/RouterConstants';
@Component({
selector: 'app-root',
imports: [RouterLink, RouterOutlet, TopbarComponent, TranslatePipe, ParticleBackgroundComponent],
templateUrl: './app.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './app.component.scss'
})
export class AppComponent {
private readonly seo = inject(SeoService);
protected readonly RouterConstants = RouterConstants;
currentYear = new Date().getFullYear();
constructor() {
@@ -1,72 +0,0 @@
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@Component({
imports: [MatDialogModule, MatButtonModule, MatIconModule],
template: `
<div class="dialog">
<div class="topbar">
<div class="title">{{ data.title }}</div>
<button mat-icon-button mat-dialog-close aria-label="Schließen">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="stage">
<img class="full" [src]="data.src" [alt]="data.title" />
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.Eager,
styles: [`
.dialog {
display: flex;
flex-direction: column;
height: 100%;
}
.topbar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 14px 16px;
}
.title {
font-weight: 600;
line-height: 1.2;
max-width: calc(100% - 56px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage {
flex: 1 1 auto;
padding: 0 16px 16px;
min-height: 0;
display: grid;
place-items: center;
}
.full {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 12px;
}
`],
})
export class ImageDialogComponent {
data = inject<{
title: string;
src: string;
}>(MAT_DIALOG_DATA);
}
+1 -11
View File
@@ -1,6 +1,6 @@
<mat-toolbar class="flex! items-center! p-[clamp(0.5rem,1vw,1rem)]! backdrop-blur-sm! backdrop-saturate-[1.1]! bg-white/80! dark:bg-[#313131]/80! border-b! border-black/8!" color="primary" (keydown)="onKeydown($event)">
<a class="flex items-center gap-[clamp(0.4rem,1vw,0.6rem)] text-inherit no-underline" routerLink="/">
<img class="w-[clamp(36px,10vw,48px)] h-auto rounded-full" src="{{AssetsConstants.LOGO}}" width="117" height="128" alt="" aria-hidden="true" draggable="false"
<img class="w-[clamp(36px,10vw,48px)] h-auto rounded-full" [ngSrc]="AssetsConstants.LOGO" width="117" height="128" loading="eager" alt="" aria-hidden="true" draggable="false"
oncontextmenu="return false;">
<span class="font-semibold tracking-[0.2px] text-[clamp(1rem,3vw,1.2rem)]">{{ 'APP.TITLE' | translate }}</span>
</a>
@@ -12,10 +12,6 @@
[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"
[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>
<!-- Mobile nav menu button -->
@@ -36,12 +32,6 @@
<button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK">
{{ 'TOPBAR.ALGORITHMS' | translate }}
</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>
<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']);
});
});
+3 -3
View File
@@ -1,10 +1,11 @@
import { Component, computed, inject, ChangeDetectionStrategy } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { NgOptimizedImage } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { ThemeService } from '../../service/theme.service';
import { LanguageService } from '../../service/language.service';
@@ -17,10 +18,9 @@ import {RouterConstants} from '../../constants/RouterConstants';
imports: [
RouterLink, RouterLinkActive,
MatToolbarModule, MatIconModule, MatButtonModule, MatMenuModule, MatTooltipModule,
TranslatePipe, MatDivider
TranslatePipe, MatDivider, NgOptimizedImage
],
templateUrl: './topbar.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './topbar.component.scss'
})
export class TopbarComponent {
+1 -4
View File
@@ -24,10 +24,7 @@
</div>
<div class="flex items-center flex-wrap gap-[0.4rem]">
<mat-icon aria-hidden="true">email</mat-icon>
<button type="button" class="link-button" data-testid="contact-mail"
(click)="SharedFunctions.openMail($event)">
{{ 'ABOUT.CONTACT_ME' | translate }}
</button>
<a href="mailto:info@andreas-dahm.eu" data-testid="contact-mail">info&#64;andreas-dahm.eu</a>
</div>
<div class="flex items-center flex-wrap gap-[0.4rem]">
<mat-icon aria-hidden="true">data_object</mat-icon>
+35 -54
View File
@@ -1,11 +1,12 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, provideZonelessChangeDetection } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { provideZonelessChangeDetection } from '@angular/core';
import { By, DomSanitizer } from '@angular/platform-browser';
import { MatIconRegistry } from '@angular/material/icon';
import { provideRouter } from '@angular/router';
import { provideTranslateService } from '@ngx-translate/core';
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
@@ -152,6 +153,26 @@ describe('AboutComponent', () => {
});
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', () => {
expect(textOf('mat-card strong')).toContain('ABOUT.PROJECT.TBP.TITLE');
@@ -167,11 +188,14 @@ describe('AboutComponent', () => {
describe('education', () => {
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"]')).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');
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"]').length).toBe(7);
});
});
@@ -180,65 +204,22 @@ describe('AboutComponent', () => {
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
.queryAll(By.css('a'))
.filter(anchor => anchor.nativeElement.getAttribute('href') === '');
expect(emptyLinks.length).toBe(0);
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
});
it('keeps the mail button in the same font as the links it sits next to', () => {
const mailButton = fixture.debugElement.query(By.css('[data-testid="contact-mail"]')).nativeElement;
const siblingLink = fixture.debugElement
.queryAll(By.css('a'))
.find(anchor => anchor.nativeElement.textContent.trim() === 'Gitea')!.nativeElement;
const buttonStyle = getComputedStyle(mailButton);
const linkStyle = getComputedStyle(siblingLink);
expect(buttonStyle.fontSize).toBe(linkStyle.fontSize);
expect(buttonStyle.fontFamily).toBe(linkStyle.fontFamily);
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');
});
});
});
/**
* Material typography (`brand-family` in styles.scss) and the Tailwind body
* stack are declared in two separate places and are easy to change apart. When
* they disagree, only visitors who happen to have the extra font installed
* locally see it, so it will not show up in review.
*/
describe('global font stack', () => {
@Component({
imports: [MatCardModule],
template: `<mat-card><mat-card-title>probe</mat-card-title></mat-card>`,
})
class FontProbeComponent {}
function normalizeStack(value: string): string {
return value
.replace(/["']/g, '')
.split(',')
.map(family => family.trim())
.filter(family => family.length > 0)
.join(', ');
function resolve(translations: unknown, key: string): unknown {
return key
.split('.')
.reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], translations);
}
it('resolves Material card titles to the same font stack as body copy', async () => {
await TestBed.configureTestingModule({
imports: [FontProbeComponent],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const probe = TestBed.createComponent(FontProbeComponent);
probe.detectChanges();
const title = probe.nativeElement.querySelector('.mat-mdc-card-title') as HTMLElement;
const titleStack = normalizeStack(getComputedStyle(title).fontFamily);
const bodyStack = normalizeStack(getComputedStyle(document.body).fontFamily);
expect(titleStack).toBe(bodyStack);
});
});
+1 -4
View File
@@ -1,4 +1,4 @@
import { Component, computed, signal, ChangeDetectionStrategy } from '@angular/core';
import { Component, computed, signal } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
@@ -9,7 +9,6 @@ import { TranslatePipe } from '@ngx-translate/core';
import {UrlConstants} from '../../constants/UrlConstants';
import {AssetsConstants} from '../../constants/AssetsConstants';
import {RouterLink} from '@angular/router';
import {SharedFunctions} from '../../shared/SharedFunctions';
/** One role. Consecutive entries sharing a `company` render as a single employer block. */
interface ExperienceEntry {
@@ -37,7 +36,6 @@ interface SkillGroup {
RouterLink
],
templateUrl: './about.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './about.component.scss'
})
export class AboutComponent {
@@ -268,5 +266,4 @@ export class AboutComponent {
protected readonly UrlConstants = UrlConstants;
protected readonly AssetsConstants = AssetsConstants;
protected readonly SharedFunctions = SharedFunctions;
}
@@ -1,4 +1,4 @@
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { Component, inject } from '@angular/core';
import { AlgorithmsService } from './algorithms.service';
import { AlgorithmCategory } from './algorithm-category';
import { RouterLink } from '@angular/router';
@@ -10,7 +10,6 @@ import {TranslatePipe} from '@ngx-translate/core';
selector: 'app-algorithms',
templateUrl: './algorithms.component.html',
styleUrl: './algorithms.component.scss',
changeDetection: ChangeDetectionStrategy.Eager,
imports: [RouterLink, MatCardModule, MatIconModule, TranslatePipe],
})
export class AlgorithmsComponent {
@@ -43,6 +43,20 @@ export class AlgorithmsService {
routerLink: RouterConstants.FRACTAL.LINK,
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',
title: 'ALGORITHM.FRACTAL3D.TITLE',
@@ -64,12 +78,61 @@ export class AlgorithmsService {
routerLink: RouterConstants.CLOTH.LINK,
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',
title: 'ALGORITHM.FOUR_COLOR.TITLE',
description: 'ALGORITHM.FOUR_COLOR.DESCRIPTION',
routerLink: RouterConstants.FOUR_COLOR.LINK,
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;
}
}
@@ -7,22 +7,18 @@
<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="primary" (click)="toggleWind()">
{{ isWindActive ? ('CLOTH.WIND_OFF' | translate) : ('CLOTH.WIND_ON' | translate) }}
{{ isWindActive() ? ('CLOTH.WIND_OFF' | translate) : ('CLOTH.WIND_ON' | translate) }}
</button>
<button mat-raised-button color="primary" (click)="toggleMesh()">
{{ isOutlineActive ? ('CLOTH.OUTLINE_OFF' | translate) : ('CLOTH.OUTLINE_ON' | translate) }}
{{ isOutlineActive() ? ('CLOTH.OUTLINE_OFF' | translate) : ('CLOTH.OUTLINE_ON' | translate) }}
</button>
<button mat-raised-button color="accent" (click)="restartSimulation()">
{{ 'CLOTH.RESTART_SIMULATION' | translate }}
</button>
</div>
<div class="flex items-center gap-2.5">
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation }}</span>
<mat-slider min="0.5" max="2.0" step="0.1">
<input matSliderThumb [(ngModel)]="elongation">
</mat-slider>
</div>
<app-slider-field label="CLOTH.ELONGATION" [min]="0.5" [max]="2.0" [step]="0.1" [(value)]="elongation"/>
</div>
<app-metrics [metrics]="metrics()"/>
<app-babylon-canvas
[config]="renderConfig"
(sceneReady)="onSceneReady($event)"
@@ -1,17 +1,20 @@
import {Component, ChangeDetectionStrategy} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Component, computed, signal} from '@angular/core';
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 {Engine, Scene, WebGPUEngine} from '@babylonjs/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
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 {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
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 {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
@Component({
selector: 'app-cloth',
@@ -23,12 +26,11 @@ import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
TranslatePipe,
BabylonCanvas,
MatButton,
MatSliderModule,
FormsModule,
Information
SliderFieldComponent,
Information,
MetricsComponent
],
templateUrl: './cloth.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './cloth.component.scss',
})
export class ClothComponent {
@@ -36,10 +38,23 @@ export class ClothComponent {
private simulationTime: number = 0;
private strategy: ClothSimulationStrategy | null = null;
public isWindActive: boolean = false;
public isOutlineActive: boolean = false;
public readonly isWindActive = signal(false);
public readonly isOutlineActive = signal(false);
public stiffness: number = 80;
public elongation: number = 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 = {
mode: '3D',
@@ -76,7 +91,9 @@ export class ClothComponent {
],
disclaimer: 'CLOTH.EXPLANATION.DISCLAIMER',
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 {
@@ -85,16 +102,16 @@ export class ClothComponent {
}
public toggleWind(): void {
this.isWindActive = !this.isWindActive;
this.isWindActive.update(v => !v);
}
public toggleMesh(): void {
this.isOutlineActive = !this.isOutlineActive;
this.isOutlineActive.update(v => !v);
const mesh = this.strategy?.getMesh();
if (!mesh?.material) {
return;
}
mesh.material.wireframe = this.isOutlineActive;
mesh.material.wireframe = this.isOutlineActive();
}
public restartSimulation(): void {
@@ -108,7 +125,7 @@ export class ClothComponent {
}
const {engine, scene, gpuTier} = this.currentSceneData;
const config = this.getClothConfig();
const config = this.clothConfig;
if (this.strategy) {
this.strategy.dispose();
@@ -118,24 +135,27 @@ export class ClothComponent {
? new ClothGpuStrategy()
: new ClothCpuStrategy();
this.strategy.init(scene, engine, config);
this.startParamUpdateLoop(scene, engine);
this.backend.set(gpuTier === 'webgpu' ? 'WebGPU (Compute)' : 'WebGL + CPU');
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.add(() => {
this.fpsCounter.tick();
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;
}
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 {
positions: Float32Array;
prevPositions: Float32Array;
@@ -0,0 +1,89 @@
import {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics';
const params: ClothPhysicsParams = {
dt: 0.1, gravityY: -10, compliance: 0, numVertices: 2,
windX: 0, windY: 0, windZ: 0, time: 0, elongation: 1,
};
/** Vertex layout: [x, y, z, invMass]. */
function vertices(...rows: number[][]): Float32Array {
return new Float32Array(rows.flat());
}
describe('cloth cpu physics', () => {
describe('integratePositions', () => {
it('never moves a pinned vertex (invMass 0)', () => {
const positions = vertices([1, 2, 3, 0], [0, 0, 0, 1]);
const prev = new Float32Array(8);
const velocities = new Float32Array(8);
integratePositions(positions, prev, velocities, params);
expect(Array.from(positions.slice(0, 4))).toEqual([1, 2, 3, 0]);
expect(Array.from(velocities.slice(0, 4))).toEqual([0, 0, 0, 0]);
});
it('lets a free vertex fall by g*dt*dt and stores the old position', () => {
const positions = vertices([0, 5, 0, 1], [0, 0, 0, 0]);
const prev = new Float32Array(8);
const velocities = new Float32Array(8);
integratePositions(positions, prev, velocities, params);
expect(positions[1]).toBeCloseTo(5 + params.gravityY * params.dt * params.dt);
expect(prev[1]).toBe(5);
});
});
describe('solveConstraints', () => {
it('pulls two free vertices to restLength * elongation', () => {
const positions = vertices([0, 0, 0, 1], [4, 0, 0, 1]);
const constraints = new Float32Array([0, 1, 1, 1]);
solveConstraints(positions, constraints, {...params, elongation: 1.5});
expect(positions[4] - positions[0]).toBeCloseTo(1.5);
});
it('moves only the free side when the other is pinned', () => {
const positions = vertices([0, 0, 0, 0], [4, 0, 0, 1]);
const constraints = new Float32Array([0, 1, 1, 1]);
solveConstraints(positions, constraints, params);
expect(positions[0]).toBe(0);
expect(positions[4]).toBeCloseTo(1);
});
it('skips constraints of type 0', () => {
const positions = vertices([0, 0, 0, 1], [4, 0, 0, 1]);
solveConstraints(positions, new Float32Array([0, 1, 1, 0]), params);
expect(positions[4]).toBe(4);
});
it('skips coincident vertices', () => {
const positions = vertices([1, 1, 1, 1], [1, 1, 1, 1]);
solveConstraints(positions, new Float32Array([0, 1, 1, 1]), params);
expect(Array.from(positions)).toEqual([1, 1, 1, 1, 1, 1, 1, 1]);
});
});
describe('updateVelocities', () => {
it('derives velocity from the position change and leaves pinned vertices alone', () => {
const positions = vertices([1, 0, 0, 0], [3, 2, 1, 1]);
const prev = vertices([0, 0, 0, 0], [1, 2, 0, 1]);
const velocities = vertices([9, 9, 9, 9], [0, 0, 0, 0]);
updateVelocities(positions, prev, velocities, params);
expect(Array.from(velocities.slice(0, 4))).toEqual([9, 9, 9, 9]);
expect(velocities[4]).toBeCloseTo(20);
expect(velocities[5]).toBeCloseTo(0);
expect(velocities[6]).toBeCloseTo(10);
});
});
});
@@ -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 {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 {ClothPhysicsParams, integratePositions, solveConstraints, updateVelocities} from './cloth-cpu-physics';
import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy';
@@ -8,11 +8,13 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
private clothMesh: GroundMesh | null = null;
private scene: Scene | null = null;
private config: ClothConfig | null = null;
private readParams: (() => ClothSimulationParams) | null = null;
private positions!: Float32Array;
private prevPositions!: Float32Array;
private velocities!: Float32Array;
private constraintPhases!: Float32Array[];
private stats: ClothStats = {constraints: 0, phases: 0};
private physicsParams: ClothPhysicsParams = {
dt: 0.016,
@@ -26,8 +28,9 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
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.readParams = readParams;
this.config = config;
this.physicsParams.numVertices = config.numVertices;
@@ -36,12 +39,29 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
this.prevPositions = clothData.prevPositions;
this.velocities = clothData.velocities;
this.constraintPhases = clothData.constraints.map(c => new Float32Array(c));
this.stats = clothStatsOf(clothData.constraints);
this.setupRenderMesh(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 windZ = params.isWindActive ? 15.0 : 0.0;
@@ -57,17 +77,6 @@ export class ClothCpuStrategy implements ClothSimulationStrategy {
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 {
const positionsData = 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 {
const substeps = 15;
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.clothMesh) {
if (!this.clothMesh || !this.readParams) {
return;
}
this.applyParams(this.readParams());
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) {
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 {ClothConfig, ClothData} from '../cloth.model';
import {CLOTH_SUBSTEPS, ClothConfig, ClothData, ClothStats, clothStatsOf} from '../cloth.model';
import {
CLOTH_FRAGMENT_SHADER_WGSL,
CLOTH_INTEGRATE_COMPUTE_WGSL,
@@ -32,13 +32,17 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
private dispatchXConstraints: number[] = [];
private dispatchXVertices = 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.readParams = readParams;
this.numVertices = config.numVertices;
const gpuEngine = engine as WebGPUEngine;
const clothData = this.generateClothData(config);
this.stats = clothStatsOf(clothData.constraints);
this.buffers = this.createStorageBuffers(gpuEngine, clothData);
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers);
this.setupRenderMesh(scene, config, this.buffers.positions);
@@ -46,7 +50,25 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
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) {
return;
}
@@ -70,19 +92,6 @@ export class ClothGpuStrategy implements ClothSimulationStrategy {
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 {
const positionsData = 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 {
const substeps = 15;
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.buffers || !this.pipelines) {
if (!this.buffers || !this.pipelines || !this.readParams) {
return;
}
this.applyParams(this.readParams());
this.buffers.params.update(this.paramsData);
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++) {
this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1);
}
@@ -1,5 +1,5 @@
import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core';
import {ClothConfig} from '../cloth.model';
import {ClothConfig, ClothStats} from '../cloth.model';
export interface ClothSimulationParams {
stiffness: number;
@@ -10,8 +10,8 @@ export interface ClothSimulationParams {
}
export interface ClothSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void;
updateParams(params: ClothSimulationParams): void;
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig, readParams: () => ClothSimulationParams): void;
getMesh(): GroundMesh | null;
getStats(): ClothStats;
dispose(): void;
}
@@ -33,8 +33,8 @@
<mat-icon>play_arrow</mat-icon> {{ 'GOL.START' | translate }}
</button>
}
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
</div>
<app-metrics [metrics]="metrics()"/>
<div class="flex gap-3 items-center flex-wrap">
<mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
@@ -44,7 +44,8 @@
[(ngModel)]="gridRows"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(ngModelChange)="pauseGame(); genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
(ngModelChange)="applyGridRows($event)"
(blur)="showClampedGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="w-[150px]">
@@ -55,7 +56,8 @@
[(ngModel)]="gridCols"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
(ngModelChange)="pauseGame(); genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
(ngModelChange)="applyGridCols($event)"
(blur)="showClampedGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="w-[150px]">
@@ -78,8 +80,8 @@
</div>
</div>
<app-generic-grid
[gridRows]="gridRows"
[gridCols]="gridCols"
[gridRows]="gridRows()"
[gridCols]="gridCols()"
[minGridSize]="MIN_GRID_SIZE"
[maxGridSize]="MAX_GRID_SIZE"
[maxGridPx]="MAX_GRID_PX"
@@ -0,0 +1,82 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {provideZonelessChangeDetection} from '@angular/core';
import {provideNoopAnimations} from '@angular/platform-browser/animations';
import {provideRouter} from '@angular/router';
import {provideTranslateService} from '@ngx-translate/core';
import {ConwayGolComponent} from './conway-gol.component';
describe('ConwayGolComponent', () => {
let fixture: ComponentFixture<ConwayGolComponent>;
let component: ConwayGolComponent;
const originalGetContext = HTMLCanvasElement.prototype.getContext;
beforeEach(async () => {
// jsdom has no 2D context.
const fakeContext = {
fillRect: vi.fn(), strokeRect: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(),
fillStyle: '', strokeStyle: '', lineWidth: 1,
};
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
configurable: true,
value: () => fakeContext,
});
await TestBed.configureTestingModule({
imports: [ConwayGolComponent],
providers: [
provideZonelessChangeDetection(),
provideNoopAnimations(),
provideRouter([]),
provideTranslateService({lang: 'en', fallbackLang: 'en'}),
],
}).compileComponents();
fixture = TestBed.createComponent(ConwayGolComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
component.pauseGame();
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {configurable: true, value: originalGetContext});
});
it('runs a generation on a grid that the generic grid clamped below the typed size', async () => {
component['gridCols'].set(500); // what ngModel does before ngModelChange
component['applyGridCols'](500);
const run = component.startGame();
component.pauseGame();
await expect(run).resolves.toBeUndefined();
expect(component.readGrid[0].length).toBe(200);
});
it('shows the clamped size in the input signals', () => {
component['gridRows'].set(500);
component['applyGridRows'](500);
expect(component['gridRows']()).toBe(200);
});
it('keeps a cleared input empty', () => {
const cleared = null as unknown as number; // what ngModel emits for an empty number input
component['gridCols'].set(cleared);
component['applyGridCols'](cleared);
expect(component['gridCols']()).toBeNull();
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,15 +1,20 @@
import {AfterViewInit, Component, signal, ViewChild, ChangeDetectionStrategy} from '@angular/core';
import {AfterViewInit, Component, computed, inject, signal, ViewChild} from '@angular/core';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card";
import {TranslatePipe} from "@ngx-translate/core";
import {UrlConstants} from '../../../constants/UrlConstants';
import {RouterConstants} from '../../../constants/RouterConstants';
import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {MatButton} from '@angular/material/button';
import {MatIcon} from '@angular/material/icon';
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, DEFAULT_TIME_PER_GENERATION, LIVE_SPAWN_PROBABILITY, 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 {ConwayGolService} from './service/conway-gol.service';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {FpsCounter} from '../../../shared/fps-counter';
@Component({
selector: 'app-conway-gol',
@@ -27,12 +32,13 @@ import {GenericGridComponent, GridPos} from '../../../shared/components/generic-
MatLabel,
ReactiveFormsModule,
FormsModule,
GenericGridComponent
GenericGridComponent,
MetricsComponent
],
changeDetection: ChangeDetectionStrategy.Eager,
templateUrl: './conway-gol.component.html',
})
export class ConwayGolComponent implements AfterViewInit {
private readonly golService = inject(ConwayGolService);
algoInformation: AlgorithmInformation = {
title: 'GOL.EXPLANATION.TITLE',
@@ -41,23 +47,39 @@ export class ConwayGolComponent implements AfterViewInit {
name: '',
description: 'GOL.EXPLANATION.EXPLANATION',
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',
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 gridCols = DEFAULT_GRID_COLS;
protected gridRows = DEFAULT_GRID_ROWS;
protected lifeSpeed = DEFAULT_TIME_PER_GENERATION;
protected readonly gridCols = signal(DEFAULT_GRID_COLS);
protected readonly gridRows = signal(DEFAULT_GRID_ROWS);
protected readonly lifeSpeed = signal(DEFAULT_TIME_PER_GENERATION);
protected readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
protected readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
protected readonly MAX_GRID_PX = MAX_GRID_PX;
readGrid: Node[][] = [];
writeGrid: Node[][] = [];
executionTime = 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;
readonly gameStarted = signal(false);
@@ -69,8 +91,8 @@ export class ConwayGolComponent implements AfterViewInit {
this.genericGridComponent.createNodeFn = this.createConwayNode;
this.genericGridComponent.getNodeColorFn = this.getConwayNodeColor;
this.genericGridComponent.applySelectionFn = this.applyConwaySelection;
this.genericGridComponent.gridRows = this.gridRows;
this.genericGridComponent.gridCols = this.gridCols;
this.genericGridComponent.gridRows = this.gridRows();
this.genericGridComponent.gridCols = this.gridCols();
this.genericGridComponent.minGridSize = this.MIN_GRID_SIZE;
this.genericGridComponent.maxGridSize = this.MAX_GRID_SIZE;
this.genericGridComponent.maxGridPx = this.MAX_GRID_PX;
@@ -86,7 +108,36 @@ export class ConwayGolComponent implements AfterViewInit {
}
applySpeed(): void {
this.lifeSpeed = Math.min(Math.max(this.lifeSpeed, MIN_TIME_PER_GENERATION), MAX_TIME_PER_GENERATION);
this.lifeSpeed.set(Math.min(Math.max(this.lifeSpeed(), MIN_TIME_PER_GENERATION), MAX_TIME_PER_GENERATION));
}
protected applyGridRows(rows: number): void {
this.pauseGame();
this.genericGridComponent.gridRows = rows;
this.applyClampedGridSize();
}
protected applyGridCols(cols: number): void {
this.pauseGame();
this.genericGridComponent.gridCols = cols;
this.applyClampedGridSize();
}
private applyClampedGridSize(): void {
this.genericGridComponent.applyGridSize();
// Only clamp down while typing; raising to the minimum would fight the user typing "100".
// A cleared input emits null: keep it as it is.
this.gridRows.update(rows => this.clampDown(rows, this.genericGridComponent.gridRows));
this.gridCols.update(cols => this.clampDown(cols, this.genericGridComponent.gridCols));
}
private clampDown(value: number, max: number): number {
return Number.isFinite(value) && value > max ? max : value;
}
protected showClampedGridSize(): void {
this.gridRows.set(this.genericGridComponent.gridRows);
this.gridCols.set(this.genericGridComponent.gridCols);
}
// --- Callbacks for GenericGridComponent ---
@@ -110,67 +161,13 @@ export class ConwayGolComponent implements AfterViewInit {
initializeConwayGrid = (grid: Node[][]): void => {
this.gameStarted.set(false);
this.generation.set(0);
this.generationRate.reset();
this.readGrid = grid;
switch(this.currentScenario) {
case Scenario.RANDOM: this.setupRandomLives(); break;
case Scenario.SIMPLE: this.setupSimpleLive(); break;
case Scenario.PULSAR: this.setupPulsar(); break;
case Scenario.GUN: this.setupGliderGun(); break;
}
this.golService.applyScenario(grid, this.currentScenario);
this.writeGrid = structuredClone(this.readGrid);
};
// --- Conway-specific logic (kept local) ---
setupRandomLives(): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
this.readGrid[row][col].alive = Math.random() <= LIVE_SPAWN_PROBABILITY;
}
}
}
setupSimpleLive(): void {
this.readGrid[3][4].alive = true;
this.readGrid[4][5].alive = true;
this.readGrid[5][3].alive = true;
this.readGrid[5][4].alive = true;
this.readGrid[5][5].alive = true;
}
setupPulsar(): void {
const centerRow = Math.floor(this.gridRows / 2);
const centerCol = Math.floor(this.gridCols / 2);
const rows = [-6, -1, 1, 6];
const offsets = [2, 3, 4];
rows.forEach(r => {
offsets.forEach(c => {
this.setAlive(centerRow + r, centerCol + c);
this.setAlive(centerRow + r, centerCol - c);
this.setAlive(centerRow + c, centerCol + r);
this.setAlive(centerRow - c, centerCol + r);
});
});
}
setupGliderGun(): void {
const r = 5;
const c = 5;
const dots = [
[r+4, c], [r+4, c+1], [r+5, c], [r+5, c+1], // Block links
[r+4, c+10], [r+5, c+10], [r+6, c+10], [r+3, c+11], [r+7, c+11],
[r+2, c+12], [r+8, c+12], [r+2, c+13], [r+8, c+13], [r+5, c+14],
[r+3, c+15], [r+7, c+15], [r+4, c+16], [r+5, c+16], [r+6, c+16], [r+5, c+17],
[r+2, c+20], [r+3, c+20], [r+4, c+20], [r+2, c+21], [r+3, c+21], [r+4, c+21],
[r+1, c+22], [r+5, c+22], [r+0, c+24], [r+1, c+24], [r+5, c+24], [r+6, c+24],
[r+2, c+34], [r+3, c+34], [r+2, c+35], [r+3, c+35]
];
dots.forEach(([row, col]) => this.setAlive(row, col));
}
// --- The rules of the game
pauseGame(): void {
@@ -181,35 +178,22 @@ export class ConwayGolComponent implements AfterViewInit {
this.gameStarted.set(true);
while (this.gameStarted()){
const startTime = performance.now();
let lifeIsDead = true;
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
lifeIsDead = this.checkLifeRules(row, col, this.writeGrid) && lifeIsDead;
}
}
const changed = this.golService.nextGeneration(this.readGrid, this.writeGrid);
this.swapGrids();
this.generation.update(count => count + 1);
this.generationRate.tick();
const endTime = performance.now();
this.executionTime = Number.parseFloat((endTime - startTime).toFixed(4));
if (lifeIsDead){
const elapsed = Number.parseFloat((endTime - startTime).toFixed(4));
this.executionTime.set(elapsed);
if (!changed){
this.gameStarted.set(false);
}
const delta = Math.max(this.lifeSpeed - this.executionTime, 0);
const delta = Math.max(this.lifeSpeed() - elapsed, 0);
await this.delay(delta);
}
this.executionTime = 0;
}
private checkLifeRules(row: number, col: number, writeGrid: Node[][]): boolean {
const currentCell = this.readGrid[row][col];
const aliveNeighbors = this.howManyNeighborsAreLiving(row, col);
const oldLifeState = currentCell.alive;
const nextStateAlive = (currentCell.alive && (aliveNeighbors === 2 || aliveNeighbors === 3)) || (!currentCell.alive && aliveNeighbors === 3);
writeGrid[row][col].alive = nextStateAlive;
//only if at least one cell changes the game is still alive
return (nextStateAlive == oldLifeState);
this.executionTime.set(0);
this.generationRate.reset();
}
private swapGrids() {
@@ -222,27 +206,6 @@ export class ConwayGolComponent implements AfterViewInit {
}
}
private howManyNeighborsAreLiving(row: number, col: number): number {
let aliveNeighborCount = 0;
const minRow = Math.max(row - 1, 0);
const minCol = Math.max(col - 1, 0);
const maxRow = Math.min(row + 1, this.gridRows - 1);
const maxCol = Math.min(col + 1, this.gridCols - 1);
for (let nRow = minRow; nRow <= maxRow; nRow++) {
for (let nCol = minCol; nCol <= maxCol; nCol++) {
if (nRow == row && nCol == col) {
continue;
}
if (this.readGrid[nRow][nCol].alive) {
aliveNeighborCount++;
}
}
}
return aliveNeighborCount;
}
// --- Other methods ---
protected readonly Scenario = Scenario;
protected readonly MIN_TIME_PER_GENERATION = MIN_TIME_PER_GENERATION;
@@ -251,10 +214,4 @@ export class ConwayGolComponent implements AfterViewInit {
delay(ms: number) {
return new Promise( resolve => setTimeout(resolve, ms) );
}
private setAlive(r: number, c: number): void {
if (r >= 0 && r < this.gridRows && c >= 0 && c < this.gridCols) {
this.readGrid[r][c].alive = true;
}
}
}
@@ -0,0 +1,137 @@
import {ConwayGolService} from './conway-gol.service';
import {Node, Scenario} from '../conway-gol.models';
function createGrid(rows: number, cols: number, aliveCells: number[][] = []): Node[][] {
const grid = Array.from({length: rows}, (_, row) =>
Array.from({length: cols}, (_, col): Node => ({row, col, alive: false})));
for (const [row, col] of aliveCells) {
grid[row][col].alive = true;
}
return grid;
}
function aliveCells(grid: Node[][]): string[] {
return grid.flat().filter(node => node.alive).map(node => `${node.row},${node.col}`);
}
describe('ConwayGolService', () => {
const service = new ConwayGolService();
describe('countLivingNeighbors', () => {
it('counts all neighbors of a center cell but not the cell itself', () => {
const grid = createGrid(3, 3);
grid.flat().forEach(node => node.alive = true);
expect(service.countLivingNeighbors(grid, 1, 1)).toBe(8);
});
it('counts only in-bounds neighbors on corners and edges', () => {
const grid = createGrid(3, 3);
grid.flat().forEach(node => node.alive = true);
expect(service.countLivingNeighbors(grid, 0, 0)).toBe(3);
expect(service.countLivingNeighbors(grid, 0, 1)).toBe(5);
expect(service.countLivingNeighbors(grid, 2, 2)).toBe(3);
});
});
describe('nextGeneration', () => {
function step(read: Node[][]): { next: Node[][]; changed: boolean } {
const next = structuredClone(read);
const changed = service.nextGeneration(read, next);
return {next, changed};
}
it('flips a blinker and reports a change', () => {
const {next, changed} = step(createGrid(5, 5, [[2, 1], [2, 2], [2, 3]]));
expect(changed).toBe(true);
expect(aliveCells(next)).toEqual(['1,2', '2,2', '3,2']);
});
it('keeps a block and reports no change', () => {
const block = [[1, 1], [1, 2], [2, 1], [2, 2]];
const {next, changed} = step(createGrid(4, 4, block));
expect(changed).toBe(false);
expect(aliveCells(next)).toEqual(['1,1', '1,2', '2,1', '2,2']);
});
it('kills a lonely cell (underpopulation)', () => {
expect(aliveCells(step(createGrid(3, 3, [[1, 1]])).next)).toEqual([]);
});
it('kills a cell with four neighbors (overpopulation)', () => {
const {next} = step(createGrid(3, 3, [[1, 1], [0, 1], [2, 1], [1, 0], [1, 2]]));
expect(next[1][1].alive).toBe(false);
});
it('births a dead cell with exactly three neighbors', () => {
const {next} = step(createGrid(3, 3, [[0, 0], [0, 1], [0, 2]]));
expect(next[1][1].alive).toBe(true);
});
});
describe('applyScenario', () => {
it('SIMPLE sets the five glider cells', () => {
const grid = createGrid(50, 50);
service.applyScenario(grid, Scenario.SIMPLE);
expect(aliveCells(grid).sort()).toEqual(['3,4', '4,5', '5,3', '5,4', '5,5']);
});
it('PULSAR is symmetric around the center', () => {
const grid = createGrid(50, 50);
service.applyScenario(grid, Scenario.PULSAR);
const alive = grid.flat().filter(node => node.alive);
expect(alive.length).toBe(48);
for (const node of alive) {
expect(grid[50 - node.row][50 - node.col].alive).toBe(true);
}
});
it('GUN sets 36 cells on 50x50', () => {
const grid = createGrid(50, 50);
service.applyScenario(grid, Scenario.GUN);
expect(aliveCells(grid).length).toBe(36);
});
it('EMPTY changes nothing', () => {
const grid = createGrid(50, 50);
service.applyScenario(grid, Scenario.EMPTY);
expect(aliveCells(grid)).toEqual([]);
});
it('RANDOM sets at least one cell on 50x50', () => {
const grid = createGrid(50, 50);
service.applyScenario(grid, Scenario.RANDOM);
expect(aliveCells(grid).length).toBeGreaterThan(0);
});
it.each([
[Scenario.SIMPLE, 5],
[Scenario.GUN, 2],
])('sets only the in-bounds cells on a 10x10 grid for scenario %s', (scenario, expected) => {
const grid = createGrid(10, 10);
service.applyScenario(grid, scenario);
expect(aliveCells(grid).length).toBe(expected);
});
it('does not throw for PULSAR on a 10x10 grid', () => {
expect(() => service.applyScenario(createGrid(10, 10), Scenario.PULSAR)).not.toThrow();
});
});
});
@@ -0,0 +1,110 @@
import {Injectable} from '@angular/core';
import {LIVE_SPAWN_PROBABILITY, Node, Scenario} from '../conway-gol.models';
@Injectable({
providedIn: 'root'
})
export class ConwayGolService {
applyScenario(grid: Node[][], scenario: Scenario): void {
switch (scenario) {
case Scenario.RANDOM: this.setupRandomLives(grid); break;
case Scenario.SIMPLE: this.setupGlider(grid); break;
case Scenario.PULSAR: this.setupPulsar(grid); break;
case Scenario.GUN: this.setupGliderGun(grid); break;
}
}
countLivingNeighbors(grid: Node[][], row: number, col: number): number {
let aliveNeighborCount = 0;
const minRow = Math.max(row - 1, 0);
const minCol = Math.max(col - 1, 0);
const maxRow = Math.min(row + 1, grid.length - 1);
const maxCol = Math.min(col + 1, grid[0].length - 1);
for (let nRow = minRow; nRow <= maxRow; nRow++) {
for (let nCol = minCol; nCol <= maxCol; nCol++) {
if (nRow === row && nCol === col) {
continue;
}
if (grid[nRow][nCol].alive) {
aliveNeighborCount++;
}
}
}
return aliveNeighborCount;
}
/** Writes the next generation of readGrid into writeGrid. Returns true when at least one cell changed. */
nextGeneration(readGrid: Node[][], writeGrid: Node[][]): boolean {
let changed = false;
for (let row = 0; row < readGrid.length; row++) {
for (let col = 0; col < readGrid[row].length; col++) {
const wasAlive = readGrid[row][col].alive;
const aliveNeighbors = this.countLivingNeighbors(readGrid, row, col);
const nextAlive = wasAlive ? (aliveNeighbors === 2 || aliveNeighbors === 3) : aliveNeighbors === 3;
writeGrid[row][col].alive = nextAlive;
changed ||= nextAlive !== wasAlive;
}
}
return changed;
}
private setupRandomLives(grid: Node[][]): void {
for (const row of grid) {
for (const node of row) {
node.alive = Math.random() <= LIVE_SPAWN_PROBABILITY;
}
}
}
private setupGlider(grid: Node[][]): void {
this.setAlive(grid, 3, 4);
this.setAlive(grid, 4, 5);
this.setAlive(grid, 5, 3);
this.setAlive(grid, 5, 4);
this.setAlive(grid, 5, 5);
}
private setupPulsar(grid: Node[][]): void {
const centerRow = Math.floor(grid.length / 2);
const centerCol = Math.floor(grid[0].length / 2);
const rows = [-6, -1, 1, 6];
const offsets = [2, 3, 4];
for (const r of rows) {
for (const c of offsets) {
this.setAlive(grid, centerRow + r, centerCol + c);
this.setAlive(grid, centerRow + r, centerCol - c);
this.setAlive(grid, centerRow + c, centerCol + r);
this.setAlive(grid, centerRow - c, centerCol + r);
}
}
}
private setupGliderGun(grid: Node[][]): void {
const r = 5;
const c = 5;
const dots = [
[r+4, c], [r+4, c+1], [r+5, c], [r+5, c+1], // Block links
[r+4, c+10], [r+5, c+10], [r+6, c+10], [r+3, c+11], [r+7, c+11],
[r+2, c+12], [r+8, c+12], [r+2, c+13], [r+8, c+13], [r+5, c+14],
[r+3, c+15], [r+7, c+15], [r+4, c+16], [r+5, c+16], [r+6, c+16], [r+5, c+17],
[r+2, c+20], [r+3, c+20], [r+4, c+20], [r+2, c+21], [r+3, c+21], [r+4, c+21],
[r+1, c+22], [r+5, c+22], [r+0, c+24], [r+1, c+24], [r+5, c+24], [r+6, c+24],
[r+2, c+34], [r+3, c+34], [r+2, c+35], [r+3, c+35]
];
for (const [row, col] of dots) {
this.setAlive(grid, row, col);
}
}
private setAlive(grid: Node[][], row: number, col: number): void {
if (row >= 0 && row < grid.length && col >= 0 && col < grid[0].length) {
grid[row][col].alive = true;
}
}
}
@@ -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);
});
});
});
Loaded 100 of 295 files, more files were not shown because too many files have changed in this diff. Show more