Compare commits

..

6 Commits

Author SHA1 Message Date
ffe7bbb25e Merge pull request 'AI-tryouts' (#33) from AI-tryouts into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 10m49s
Build, Test & Push Frontend / docker (push) Successful in 55s
Reviewed-on: #33
2026-04-16 13:26:41 +02:00
Andreas Dahm
9a6e91ea9d Using tailwind instead of scss as much as possible
Some checks failed
Build, Test & Push Frontend / docker (pull_request) Has been cancelled
Build, Test & Push Frontend / quality-check (pull_request) Has been cancelled
2026-04-16 11:58:16 +02:00
Andreas Dahm
f9797493ce Removed unused parameter 2026-04-16 10:06:57 +02:00
Andreas Dahm
a349f630c6 Updated the webgpu stuff to have webgl as fallback 2026-04-16 10:04:48 +02:00
Andreas Dahm
4e24cb5df1 Updated dependencies 2026-04-16 08:57:55 +02:00
Andreas Dahm
0d3411aeb2 Updated about page 2026-04-16 08:22:39 +02:00
51 changed files with 4283 additions and 3386 deletions

3
.gitignore vendored
View File

@@ -45,3 +45,6 @@ Thumbs.db
# Lighthouse # Lighthouse
.lighthouseci/ .lighthouseci/
.claude/settings.local.json .claude/settings.local.json
# claude
.claude/

View File

@@ -77,7 +77,7 @@ ng build
* **Language:** TypeScript * **Language:** TypeScript
* **Framework:** Angular * **Framework:** Angular
* **Styling:** SCSS (based on `styles.scss` and component-specific `.scss` files). * **Styling:** Tailwind CSS (v3) is the primary styling approach. Use utility classes in templates. SCSS (`styles.scss`) is only for Angular Material theme setup, Material component overrides with `!important`, Swiper `::part()` selectors, and component `:host` blocks. Shared Tailwind component classes live in `src/tailwind.css` via `@layer components`.
* **Linting:** ESLint is configured (see `eslint.config.js` and `package.json` scripts). * **Linting:** ESLint is configured (see `eslint.config.js` and `package.json` scripts).
* **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files. * **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files.

View File

@@ -65,7 +65,7 @@ To build and run the application using Docker locally:
* **Language:** TypeScript * **Language:** TypeScript
* **Framework:** Angular * **Framework:** Angular
* **Styling:** SCSS (based on `styles.scss` and component-specific `.scss` files). * **Styling:** Only use tailwind, as much as possible. If not possible use SCSS (based on `styles.scss` and component-specific `.scss` files).
* **Linting:** ESLint is configured (see `eslint.config.js` and `package.json` scripts). * **Linting:** ESLint is configured (see `eslint.config.js` and `package.json` scripts).
* **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files. * **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files.

View File

@@ -40,6 +40,7 @@
} }
], ],
"styles": [ "styles": [
"src/tailwind.css",
"src/styles.scss", "src/styles.scss",
"node_modules/swiper/swiper-bundle.css" "node_modules/swiper/swiper-bundle.css"
] ]
@@ -95,6 +96,7 @@
} }
], ],
"styles": [ "styles": [
"src/tailwind.css",
"src/styles.scss" "src/styles.scss"
] ]
} }

4122
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,15 +12,15 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular-slider/ngx-slider": "^21.0.0", "@angular-slider/ngx-slider": "^21.0.0",
"@angular/animations": "~21.2.1", "@angular/animations": "~21.2.9",
"@angular/cdk": "~21.2.1", "@angular/cdk": "~21.2.6",
"@angular/common": "~21.2.1", "@angular/common": "~21.2.9",
"@angular/compiler": "~21.2.1", "@angular/compiler": "~21.2.9",
"@angular/core": "~21.2.1", "@angular/core": "~21.2.9",
"@angular/forms": "~21.2.1", "@angular/forms": "~21.2.9",
"@angular/material": "~21.2.1", "@angular/material": "~21.2.6",
"@angular/platform-browser": "~21.2.1", "@angular/platform-browser": "~21.2.9",
"@angular/router": "~21.2.1", "@angular/router": "~21.2.9",
"@babylonjs/core": "^8.54.1", "@babylonjs/core": "^8.54.1",
"@ngx-translate/core": "^17.0.0", "@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0",
@@ -30,17 +30,18 @@
"tslib": "~2.8.1" "tslib": "~2.8.1"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "~21.2.1", "@angular/build": "~21.2.7",
"@angular/cli": "~21.2.1", "@angular/cli": "~21.2.7",
"@angular/compiler-cli": "~21.2.1", "@angular/compiler-cli": "~21.2.9",
"@eslint/js": "~10.0.1", "@eslint/js": "~10.0.1",
"@lhci/cli": "^0.15.1", "@lhci/cli": "^0.15.1",
"@types/jasmine": "~6.0.0", "@types/jasmine": "~6.0.0",
"angular-eslint": "21.3.0", "angular-eslint": "21.3.1",
"eslint": "^10.0.3", "eslint": "^10.0.3",
"jasmine-core": "~6.1.0", "jasmine-core": "~6.1.0",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "8.56.1" "typescript-eslint": "8.58.2"
}, },
"overrides": { "overrides": {
"tmp": "^0.2.3" "tmp": "^0.2.3"

View File

@@ -1,9 +1,9 @@
<app-particles-background></app-particles-background> <app-particles-background></app-particles-background>
<app-topbar /> <app-topbar />
<main class="app-container app-surface"> <main class="w-full max-w-app mx-auto mt-4 grow text-app-fg transition-colors duration-[220ms]">
<router-outlet /> <router-outlet />
</main> </main>
<footer class="foot"> <footer class="border-t border-black/[.08] 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>
</footer> </footer>

View File

@@ -1,23 +1,27 @@
<mat-toolbar class="topbar" color="primary" (keydown)="onKeydown($event)"> <mat-toolbar class="!flex !items-center !p-[clamp(0.5rem,1vw,1rem)] !backdrop-blur-[8px] !backdrop-saturate-[1.1] !bg-white/80 dark:!bg-[#313131]/80 !border-b !border-black/[.08]" color="primary" (keydown)="onKeydown($event)">
<a class="brand" routerLink="/"> <a class="flex items-center gap-[clamp(0.4rem,1vw,0.6rem)] text-inherit no-underline" routerLink="/">
<img class="logo-dot" src="{{AssetsConstants.LOGO}}" alt="" aria-hidden="true" draggable="false" <img class="w-[clamp(36px,10vw,48px)] h-[clamp(36px,10vw,48px)] rounded-full" src="{{AssetsConstants.LOGO}}" alt="" aria-hidden="true" draggable="false"
oncontextmenu="return false;"> oncontextmenu="return false;">
<span class="brand-text">{{ 'APP.TITLE' | translate }}</span> <span class="font-semibold tracking-[0.2px] text-[clamp(1rem,3vw,1.2rem)]">{{ 'APP.TITLE' | translate }}</span>
</a> </a>
<nav class="nav"> <nav class="absolute left-1/2 -translate-x-1/2 flex gap-[clamp(0.25rem,1vw,0.5rem)] justify-center mobile:hidden">
<a [routerLink]="RouterConstants.ABOUT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ABOUT' | 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-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
<a [routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a> [routerLink]="RouterConstants.ABOUT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ABOUT' | translate }}</a>
<a [routerLink]="RouterConstants.ALGORITHMS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ALGORITHMS' | translate }}</a> <a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
<a [routerLink]="RouterConstants.IMPRINT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a> [routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a>
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-sm 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-sm after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
[routerLink]="RouterConstants.IMPRINT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a>
</nav> </nav>
<!-- Mobile nav menu button --> <!-- Mobile nav menu button -->
<button mat-icon-button class="nav-menu-btn" [matMenuTriggerFor]="navMenu" aria-label="Open navigation"> <button mat-icon-button class="hidden mobile:inline-flex" [matMenuTriggerFor]="navMenu" aria-label="Open navigation">
<mat-icon>menu</mat-icon> <mat-icon>menu</mat-icon>
</button> </button>
<span class="spacer"></span> <span class="flex-1"></span>
<!-- Mobile nav menu --> <!-- Mobile nav menu -->
<mat-menu #navMenu="matMenu" xPosition="before"> <mat-menu #navMenu="matMenu" xPosition="before">
@@ -35,7 +39,7 @@
</button> </button>
</mat-menu> </mat-menu>
<span class="spacer"></span> <span class="flex-1"></span>
<!-- Settings: Sprache + Theme --> <!-- Settings: Sprache + Theme -->
<button mat-icon-button [matMenuTriggerFor]="settingsMenu" aria-label="Open settings" <button mat-icon-button [matMenuTriggerFor]="settingsMenu" aria-label="Open settings"
@@ -75,4 +79,4 @@
</button> </button>
</div> </div>
</mat-menu> </mat-menu>
</mat-toolbar> </mat-toolbar>

View File

@@ -1,107 +1,6 @@
/* ---- Topbar Host & Base ---- */
:host { :host {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
display: block; display: block;
} }
.topbar {
/* Erzeugt den Milchglas-Effekt */
backdrop-filter: saturate(1.1) blur(8px);
-webkit-backdrop-filter: saturate(1.1) blur(8px);
/* Safari Support */
/* Mischt die Variable mit Transparenz. !important überschreibt Material-Vorgaben */
background: color-mix(in oklab, var(--app-topbar-bg) 80%, transparent) !important;
border-bottom: 1px solid rgba(0, 0, 0, .08);
display: flex;
align-items: center;
padding: clamp(0.5rem, 1vw, 1rem);
}
/* ---- Branding ---- */
.brand {
display: flex;
align-items: center;
gap: clamp(0.4rem, 1vw, 0.6rem);
color: inherit;
text-decoration: none;
.logo-dot {
width: clamp(36px, 10vw, 48px);
height: clamp(36px, 10vw, 48px);
border-radius: 50%;
}
.brand-text {
font-weight: 600;
letter-spacing: .2px;
font-size: clamp(1rem, 3vw, 1.2rem);
}
}
/* ---- Navigation ---- */
.nav {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: clamp(0.25rem, 1vw, 0.5rem);
justify-content: center;
}
.nav a {
opacity: 0.72;
transition: opacity 150ms ease;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 4px;
left: 10px;
right: 10px;
height: 2px;
background: currentColor;
border-radius: 2px;
transform: scaleX(0);
transition: transform 200ms ease;
}
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
&::after {
transform: scaleX(1);
}
}
}
.nav-menu-btn {
display: none;
}
.spacer {
flex: 1;
}
/* ---- Mobile Responsiveness ---- */
@media (max-width: 760px) {
.nav {
display: none;
}
.nav-menu-btn {
display: inline-flex;
}
.brand {
flex: unset;
}
}

View File

@@ -1,33 +1,34 @@
<section class="about"> <section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
<mat-card class="hero"> <mat-card>
<div class="hero-flex-container"> <div class="flex flex-wrap gap-fluid-lg p-fluid-md items-start">
<div class="photo"> <div class="flex-[1_1_min(100%,425px)] max-w-full flex justify-center">
<img [ngSrc]="AssetsConstants.ME" width="421" height="512" alt="{{ 'ABOUT.ALT.PROFILE' | translate }}" <img class="block w-full h-auto max-w-[425px] rounded-xl shadow-[0_6px_24px_rgba(0,0,0,0.25)] object-cover"
[ngSrc]="AssetsConstants.ME" width="421" height="512" alt="{{ 'ABOUT.ALT.PROFILE' | translate }}"
draggable="false" oncontextmenu="return false;" priority /> draggable="false" oncontextmenu="return false;" priority />
</div> </div>
<div class="intro"> <div class="flex-[999_1_min(100%,400px)]">
<h1>{{ 'ABOUT.HELLO' | translate }}</h1> <h1 class="mt-0 mb-2 text-[clamp(1.5rem,5vw,2.5rem)]">{{ 'ABOUT.HELLO' | translate }}</h1>
<p class="lead"> <p class="opacity-90 my-2 mb-4 text-[clamp(1rem,2.5vw,1.15rem)]">
{{ 'ABOUT.LEAD' | translate }} {{ 'ABOUT.LEAD' | translate }}
</p> </p>
<div class="meta"> <div class="flex flex-col gap-1 mb-2">
<div class="row"> <div class="flex items-center flex-wrap gap-[0.4rem]">
<mat-icon aria-hidden="true">work</mat-icon> <mat-icon aria-hidden="true">work</mat-icon>
<span>{{ 'ABOUT.ROLE' | translate }}</span> <span>{{ 'ABOUT.ROLE' | translate }}</span>
</div> </div>
<div class="row"> <div class="flex items-center flex-wrap gap-[0.4rem]">
<mat-icon aria-hidden="true">location_on</mat-icon> <mat-icon aria-hidden="true">location_on</mat-icon>
<span>{{ 'ABOUT.LOCATION' | translate }}</span> <span>{{ 'ABOUT.LOCATION' | translate }}</span>
</div> </div>
<div class="row"> <div class="flex items-center flex-wrap gap-[0.4rem]">
<mat-icon aria-hidden="true">email</mat-icon> <mat-icon aria-hidden="true">email</mat-icon>
<a href="" (click)="SharedFunctions.openMail($event)"> <a href="" (click)="SharedFunctions.openMail($event)">
{{ 'ABOUT.CONTACT_ME' | translate }} {{ 'ABOUT.CONTACT_ME' | translate }}
</a> </a>
</div> </div>
<div class="row"> <div class="flex items-center flex-wrap gap-[0.4rem]">
<mat-icon>data_object</mat-icon> <mat-icon>data_object</mat-icon>
<a href="{{UrlConstants.CODEBERG}}" target="_blank" rel="noopener">Codeberg</a> <a href="{{UrlConstants.CODEBERG}}" target="_blank" rel="noopener">Codeberg</a>
<span>·</span> <span>·</span>
@@ -39,11 +40,11 @@
</div> </div>
</mat-card> </mat-card>
<mat-card class="skills"> <mat-card class="p-[clamp(5px,2vw,15px)]">
<h2>{{ 'ABOUT.SECTION.SKILLS' | translate }}</h2> <h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.SKILLS' | translate }}</h2>
<div class="chip-groups"> <div class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,250px),1fr))] gap-fluid-sm ml-1 mb-2">
<div> <div>
<h3>{{ 'ABOUT.SECTION.BACKEND_ARCH' | translate }}</h3> <h3 class="my-1 text-[0.95rem] opacity-85">{{ 'ABOUT.SECTION.BACKEND_ARCH' | translate }}</h3>
<mat-chip-set aria-label="Backend and Architecture"> <mat-chip-set aria-label="Backend and Architecture">
@for (s of skillsArchitecture; track s) { @for (s of skillsArchitecture; track s) {
<mat-chip>{{ s | translate }}</mat-chip> <mat-chip>{{ s | translate }}</mat-chip>
@@ -52,7 +53,7 @@
</div> </div>
<div> <div>
<h3>{{ 'ABOUT.SECTION.INFRA_CLOUD' | translate }}</h3> <h3 class="my-1 text-[0.95rem] opacity-85">{{ 'ABOUT.SECTION.INFRA_CLOUD' | translate }}</h3>
<mat-chip-set aria-label="Infrastructure and Cloud"> <mat-chip-set aria-label="Infrastructure and Cloud">
@for (s of skillsCore; track s) { @for (s of skillsCore; track s) {
<mat-chip>{{ s | translate }}</mat-chip> <mat-chip>{{ s | translate }}</mat-chip>
@@ -61,7 +62,7 @@
</div> </div>
<div> <div>
<h3>{{ 'ABOUT.SECTION.SIM_ALGO' | translate }}</h3> <h3 class="my-1 text-[0.95rem] opacity-85">{{ 'ABOUT.SECTION.SIM_ALGO' | translate }}</h3>
<mat-chip-set aria-label="Simulation and Algorithms"> <mat-chip-set aria-label="Simulation and Algorithms">
@for (s of skillsEngineering; track s) { @for (s of skillsEngineering; track s) {
<mat-chip>{{ s | translate }}</mat-chip> <mat-chip>{{ s | translate }}</mat-chip>
@@ -71,31 +72,31 @@
</div> </div>
</mat-card> </mat-card>
<mat-card class="experdience"> <mat-card class="p-[clamp(5px,2vw,15px)]">
<h2 style="margin-left: 0.5rem;">{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2> <h2 class="mt-1 ml-2 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2>
<div class="xp-list"> <div class="ml-1 grid gap-fluid-sm">
@for (entry of xpKeys; track entry.key) { @for (entry of xpKeys; track entry.key) {
<div class="xp-item"> <div>
<div class="xp-head-grid"> <div class="grid grid-cols-[auto_1fr] gap-x-3">
<div class="logo-wrap"> <div class="row-span-2 col-start-1 flex items-center">
<img src="{{entry.logo}}" alt="" class="company-logo" aria-hidden="true" /> <img src="{{entry.logo}}" alt="" class="w-10 h-10 object-contain opacity-90 rounded-[10%] bg-logo-bg" aria-hidden="true" />
</div> </div>
<div class="head-row"> <div class="row-start-1 col-start-2 flex flex-wrap items-baseline gap-x-4 gap-y-1">
<strong>{{ (entry.key + '.ROLE') | translate }}</strong> <strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.ROLE') | translate }}</strong>
<span class="time">{{ (entry.key + '.TIME') | translate }}</span> <span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (entry.key + '.TIME') | translate }}</span>
</div> </div>
<div class="company-row"> <div class="row-start-2 col-start-2 mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
{{ (entry.key + '.COMPANY') | translate }} {{ (entry.key + '.COMPANY') | translate }}
</div> </div>
</div> </div>
@if (!entry.no_highlights) { @if (!entry.no_highlights) {
<div class="highlights"> <div class="mt-[0.4rem] ml-[clamp(0.25rem,1vw,0.75rem)] pl-[clamp(0.8rem,2vw,1.2rem)]">
<ul> <ul>
<li>{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li> <li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
<li>{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li> <li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
<li>{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li> <li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
</ul> </ul>
</div> </div>
} }
@@ -108,38 +109,38 @@
} }
</div> </div>
</mat-card> </mat-card>
<mat-card class="projects"> <mat-card class="p-[clamp(5px,2vw,15px)]">
<h2>{{ 'ABOUT.SECTION.PROJECTS' | translate }}</h2> <h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.PROJECTS' | translate }}</h2>
<div class="xp-list"> <div class="ml-1 grid gap-fluid-sm">
@for (entry of projectKeys; track entry.key) { @for (entry of projectKeys; track entry.key) {
<div class="xp-item"> <div>
<div class="head-row"> <div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<strong>{{ (entry.key + '.TITLE') | translate }}</strong> <strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.TITLE') | translate }}</strong>
</div> </div>
<div class="company-row"> <div class="mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
{{ (entry.key + '.DESCRIPTION') | translate }} {{ (entry.key + '.DESCRIPTION') | translate }}
</div> </div>
@if (entry.externalLink) { @if (entry.externalLink) {
<div class="link-row"> <div class="mt-[0.1rem] opacity-85">
<a class="link-with-icon" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer"> <a class="inline-flex items-center gap-[0.35rem] leading-none" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer">
<mat-icon>open_in_new</mat-icon> <mat-icon class="!text-[18px] !w-[18px] !h-[18px]">open_in_new</mat-icon>
{{ (entry.key + '.LINK_EXTERNAL') | translate }} {{ (entry.key + '.LINK_EXTERNAL') | translate }}
</a> </a>
</div> </div>
} }
<div class="link-row"> <div class="mt-[0.1rem] opacity-85">
<a class="link-with-icon" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }" <a class="inline-flex items-center gap-[0.35rem] leading-none" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
rel="noopener noreferrer"> rel="noopener noreferrer">
<mat-icon>link</mat-icon> <mat-icon class="!text-[18px] !w-[18px] !h-[18px]">link</mat-icon>
{{ (entry.key + '.LINK_INTERNAL') | translate }} {{ (entry.key + '.LINK_INTERNAL') | translate }}
</a> </a>
</div> </div>
<div class="highlights-noMargin"> <div>
<ul> <ul>
<li>{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li> <li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
<li>{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li> <li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
<li>{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li> <li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -152,23 +153,23 @@
</div> </div>
</mat-card> </mat-card>
<mat-card class="education"> <mat-card class="p-[clamp(5px,2vw,15px)]">
<h2>{{ 'ABOUT.SECTION.EDUCATION' | translate }}</h2> <h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EDUCATION' | translate }}</h2>
<div class="xp-list"> <div class="ml-1 grid gap-fluid-sm">
<div class="xp-item"> <div>
@for (entry of educationKeys; track entry.key) { @for (entry of educationKeys; track entry.key) {
<div class="head-row"> <div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<strong>{{ (entry.key + '.WHERE') | translate }}</strong> <strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.WHERE') | translate }}</strong>
<span class="time">{{ (entry.key + '.WHEN') | translate }}</span> <span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (entry.key + '.WHEN') | translate }}</span>
</div> </div>
<div class="company-row"> <div class="mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
{{ (entry.key + '.WHAT') | translate }} {{ (entry.key + '.WHAT') | translate }}
</div> </div>
@if(entry.key !== educationKeys.at(educationKeys.length-1)?.key) @if(entry.key !== educationKeys.at(educationKeys.length-1)?.key)
{ {
<mat-divider style="margin-top: .5rem; margin-bottom: .5rem"></mat-divider> <mat-divider class="!my-2"></mat-divider>
} }
} }
</div> </div>

View File

@@ -1,15 +1,15 @@
<div class="card-grid"> <div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
<h1 class="algo-page-title">{{ 'ALGORITHM.TITLE' | translate }}</h1> <h1 class="m-0 mb-2 text-[clamp(1.4rem,4vw,2rem)]">{{ 'ALGORITHM.TITLE' | translate }}</h1>
</div> </div>
<div class="card-grid"> <div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
@for (category of categories; track category.id) { @for (category of categories; track category.id) {
<mat-card class="algo-card" [routerLink]="[category.routerLink]"> <mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col cursor-pointer hover:-translate-y-1 hover:shadow-[0_8px_24px_rgba(0,0,0,0.12)]" [routerLink]="[category.routerLink]">
<mat-card-content> <mat-card-content>
<div class="algo-icon-wrap"> <div class="flex items-center text-[var(--mat-sys-primary)] mb-4">
<mat-icon>{{ category.icon }}</mat-icon> <mat-icon class="!text-[26px] !w-[26px] !h-[26px]">{{ category.icon }}</mat-icon>
</div> </div>
<h3 class="algo-card-title">{{ category.title | translate }}</h3> <h3 class="text-[1.05rem] font-semibold mb-2 m-0">{{ category.title | translate }}</h3>
<p class="algo-card-desc">{{ category.description | translate }}</p> <p class="m-0 opacity-75 text-sm leading-relaxed">{{ category.description | translate }}</p>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
} }

View File

@@ -0,0 +1,48 @@
/**
* GLSL shaders for cloth rendering on WebGL.
* Replicates the visual output of the WGSL cloth shaders:
* checkerboard pattern with Lambertian lighting.
*/
export const CLOTH_VERTEX_SHADER_GLSL = `
precision highp float;
attribute vec3 position;
attribute vec2 uv;
uniform mat4 viewProjection;
varying vec2 vUV;
varying vec3 vWorldPos;
void main() {
vUV = uv;
vWorldPos = position;
gl_Position = viewProjection * vec4(position, 1.0);
}
`;
export const CLOTH_FRAGMENT_SHADER_GLSL = `
#extension GL_OES_standard_derivatives : enable
precision highp float;
varying vec2 vUV;
varying vec3 vWorldPos;
void main() {
vec3 dx = dFdx(vWorldPos);
vec3 dy = dFdy(vWorldPos);
vec3 normal = normalize(cross(dx, dy));
vec3 lightDir = normalize(vec3(1.0, 1.0, 0.5));
float diffuse = max(0.0, abs(dot(normal, lightDir)));
float ambient = 0.3;
float lightIntensity = ambient + (diffuse * 0.7);
float grid = mod(floor(vUV.x * 20.0) + floor(vUV.y * 20.0), 2.0);
vec3 baseColor = mix(vec3(0.8, 0.4, 0.15), vec3(0.9, 0.5, 0.2), grid);
vec3 finalColor = baseColor * lightIntensity;
gl_FragColor = vec4(finalColor, 1.0);
}
`;

View File

@@ -1,11 +1,11 @@
<mat-card class="algo-container"> <mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header> <mat-card-header>
<mat-card-title>{{ 'CLOTH.TITLE' | translate }}</mat-card-title> <mat-card-title>{{ 'CLOTH.TITLE' | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<app-information [algorithmInformation]="algoInformation"/> <app-information [algorithmInformation]="algoInformation"/>
<div class="controls-container"> <div class="flex flex-col mb-4">
<div class="controls-panel"> <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()"> <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>
@@ -16,7 +16,7 @@
{{ 'CLOTH.RESTART_SIMULATION' | translate }} {{ 'CLOTH.RESTART_SIMULATION' | translate }}
</button> </button>
</div> </div>
<div class="sliders-panel"> <div class="flex items-center gap-2.5">
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation }}</span> <span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation }}</span>
<mat-slider min="0.5" max="2.0" step="0.1"> <mat-slider min="0.5" max="2.0" step="0.1">
<input matSliderThumb [(ngModel)]="elongation"> <input matSliderThumb [(ngModel)]="elongation">

View File

@@ -1,27 +1,17 @@
/** import {Component} from '@angular/core';
* File: cloth.component.ts import {FormsModule} from '@angular/forms';
* Description: Component for cloth simulation using WebGPU compute shaders. import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
*/ import {MatSliderModule} from '@angular/material/slider';
import {TranslatePipe} from '@ngx-translate/core';
import { Component } from '@angular/core'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import { FormsModule } from '@angular/forms';
import { MatCard, MatCardContent, MatCardHeader, MatCardTitle } from '@angular/material/card';
import { MatSliderModule } from '@angular/material/slider';
import { TranslatePipe } from '@ngx-translate/core';
import { BabylonCanvas, RenderConfig, SceneEventData } from '../../../shared/components/render-canvas/babylon-canvas.component';
import {ComputeShader, StorageBuffer, MeshBuilder, ShaderMaterial, ShaderLanguage, ArcRotateCamera, GroundMesh, WebGPUEngine, Scene} from '@babylonjs/core';
import {
CLOTH_FRAGMENT_SHADER_WGSL,
CLOTH_INTEGRATE_COMPUTE_WGSL,
CLOTH_SOLVE_COMPUTE_WGSL,
CLOTH_VELOCITY_COMPUTE_WGSL,
CLOTH_VERTEX_SHADER_WGSL
} from './cloth.shader';
import {MatButton} from '@angular/material/button'; import {MatButton} from '@angular/material/button';
import {ClothBuffers, ClothConfig, ClothData, ClothPipelines} from './cloth.model'; import {ClothConfig} from './cloth.model';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {ClothSimulationStrategy} from './strategies/cloth-simulation.strategy';
import {ClothGpuStrategy} from './strategies/cloth-gpu.strategy';
import {ClothCpuStrategy} from './strategies/cloth-cpu.strategy';
@Component({ @Component({
selector: 'app-cloth', selector: 'app-cloth',
@@ -43,17 +33,16 @@ import {UrlConstants} from '../../../constants/UrlConstants';
export class ClothComponent { export class ClothComponent {
private currentSceneData: SceneEventData | null = null; private currentSceneData: SceneEventData | null = null;
private simulationTime: number = 0; private simulationTime: number = 0;
private clothMesh: GroundMesh | null = null; private strategy: ClothSimulationStrategy | null = null;
public isWindActive: boolean = false; public isWindActive: boolean = false;
public isOutlineActive: boolean = false; public isOutlineActive: boolean = false;
public stiffness: number = 80; public stiffness: number = 80;
// Elongation along the vertical (Y) axis, 0.5 = compressed, 2.0 = stretched
public elongation: number = 1.0; public elongation: number = 1.0;
public renderConfig: RenderConfig = { public renderConfig: RenderConfig = {
mode: '3D', mode: '3D',
initialViewSize: 20, initialViewSize: 20
shaderLanguage: ShaderLanguage.WGSL
}; };
algoInformation: AlgorithmInformation = { algoInformation: AlgorithmInformation = {
@@ -89,10 +78,6 @@ export class ClothComponent {
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']
}; };
/**
* Called when the Babylon scene is ready.
* @param event The scene event data.
*/
public onSceneReady(event: SceneEventData): void { public onSceneReady(event: SceneEventData): void {
this.currentSceneData = event; this.currentSceneData = event;
this.createSimulation(); this.createSimulation();
@@ -104,10 +89,11 @@ export class ClothComponent {
public toggleMesh(): void { public toggleMesh(): void {
this.isOutlineActive = !this.isOutlineActive; this.isOutlineActive = !this.isOutlineActive;
if (!this.clothMesh?.material) { const mesh = this.strategy?.getMesh();
if (!mesh?.material) {
return; return;
} }
this.clothMesh.material.wireframe = this.isOutlineActive; mesh.material.wireframe = this.isOutlineActive;
} }
public restartSimulation(): void { public restartSimulation(): void {
@@ -115,36 +101,43 @@ export class ClothComponent {
this.createSimulation(); this.createSimulation();
} }
/**
* Initializes and starts the cloth simulation.
*/
private createSimulation(): void { private createSimulation(): void {
if (!this.currentSceneData) return; if (!this.currentSceneData) {
return;
}
const { engine, scene } = this.currentSceneData; const {engine, scene, gpuTier} = this.currentSceneData;
// 1. Define physics parameters
const config = this.getClothConfig(); const config = this.getClothConfig();
// 2. Generate initial CPU data (positions, constraints) if (this.strategy) {
const clothData = this.generateClothData(config); this.strategy.dispose();
}
// 3. Upload to GPU this.strategy = gpuTier === 'webgpu'
const buffers = this.createStorageBuffers(engine, clothData); ? new ClothGpuStrategy()
: new ClothCpuStrategy();
// 4. Create Compute Shaders this.strategy.init(scene, engine, config);
const pipelines = this.setupComputePipelines(engine, buffers); this.startParamUpdateLoop(scene, engine);
}
// 5. Setup Rendering (Mesh, Material, Camera)
this.setupRenderMesh(scene, config, buffers.positions); private startParamUpdateLoop(scene: any, engine: any): void {
scene.onAfterRenderObservable.clear();
// 6. Start the physics loop scene.onAfterRenderObservable.add(() => {
this.startRenderLoop(engine, scene, config, buffers, pipelines); 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
});
}
});
} }
// ========================================================================
// 1. CONFIGURATION
// ========================================================================
private getClothConfig(): ClothConfig { private getClothConfig(): ClothConfig {
const gridWidth = 100; const gridWidth = 100;
const gridHeight = 100; const gridHeight = 100;
@@ -162,239 +155,4 @@ export class ClothComponent {
particleInvMass: 1.0 / particleMass particleInvMass: 1.0 / particleMass
}; };
} }
// ========================================================================
// 2. DATA GENERATION (CPU)
// ========================================================================
private generateClothData(config: ClothConfig): ClothData {
const positionsData = new Float32Array(config.numVertices * 4);
const prevPositionsData = new Float32Array(config.numVertices * 4);
const velocitiesData = new Float32Array(config.numVertices * 4);
const constraintsP0: number[] = [];
const constraintsP1: number[] = [];
const constraintsP2: number[] = [];
const constraintsP3: number[] = [];
// Type 1.0 = horizontal/diagonal (no elongation), Type 2.0 = vertical (elongation applies)
const addHorizontalConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, config.spacing, 1.0);
};
const addVerticalConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, config.spacing, 2.0);
};
// Fill positions (Pin top row)
for (let y = 0; y < config.gridHeight; y++) {
for (let x = 0; x < config.gridWidth; x++) {
const idx = (y * config.gridWidth + x) * 4;
positionsData[idx + 0] = (x - config.gridWidth / 2) * config.spacing;
positionsData[idx + 1] = 5.0 - (y * config.spacing);
positionsData[idx + 2] = 0.0;
positionsData[idx + 3] = (y === 0) ? 0.0 : config.particleInvMass;
prevPositionsData[idx + 0] = positionsData[idx + 0];
prevPositionsData[idx + 1] = positionsData[idx + 1];
prevPositionsData[idx + 2] = positionsData[idx + 2];
prevPositionsData[idx + 3] = positionsData[idx + 3];
}
}
// Graph Coloring (4 Phases)
for (let y = 0; y < config.gridHeight; y++) {
for (let x = 0; x < config.gridWidth - 1; x += 2) addHorizontalConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1);
for (let x = 1; x < config.gridWidth - 1; x += 2) addHorizontalConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1);
}
for (let y = 0; y < config.gridHeight - 1; y += 2) {
for (let x = 0; x < config.gridWidth; x++) addVerticalConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x);
}
for (let y = 1; y < config.gridHeight - 1; y += 2) {
for (let x = 0; x < config.gridWidth; x++) addVerticalConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x);
}
const constraintsP4: number[] = [];
const constraintsP5: number[] = [];
const constraintsP6: number[] = [];
const constraintsP7: number[] = [];
const diagSpacing = config.spacing * Math.SQRT2;
const addDiagConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, diagSpacing, 1.0);
};
for (let y = 0; y < config.gridHeight - 1; y++) {
const arr = (y % 2 === 0) ? constraintsP4 : constraintsP5;
for (let x = 0; x < config.gridWidth - 1; x++) {
addDiagConstraint(arr, y * config.gridWidth + x, (y + 1) * config.gridWidth + (x + 1));
}
}
for (let y = 0; y < config.gridHeight - 1; y++) {
const arr = (y % 2 === 0) ? constraintsP6 : constraintsP7;
for (let x = 0; x < config.gridWidth - 1; x++) {
addDiagConstraint(arr, y * config.gridWidth + (x + 1), (y + 1) * config.gridWidth + x);
}
}
return {
positions: positionsData,
prevPositions: prevPositionsData,
velocities: velocitiesData,
constraints: [
constraintsP0, constraintsP1, constraintsP2, constraintsP3,
constraintsP4, constraintsP5, constraintsP6, constraintsP7
],
params: new Float32Array(9)
};
}
// ========================================================================
// 3. BUFFER CREATION (GPU)
// ========================================================================
private createStorageBuffers(engine: WebGPUEngine, data: ClothData): ClothBuffers {
const createBuffer = (arrayData: Float32Array | number[]): StorageBuffer => {
const buffer = new StorageBuffer(engine, arrayData.length * 4);
buffer.update(arrayData instanceof Float32Array ? arrayData : new Float32Array(arrayData));
return buffer;
};
return {
positions: createBuffer(data.positions),
prevPositions: createBuffer(data.prevPositions),
velocities: createBuffer(data.velocities),
params: createBuffer(data.params),
constraints: data.constraints.map(cData => createBuffer(cData))
};
}
// ========================================================================
// 4. COMPUTE SHADERS
// ========================================================================
private setupComputePipelines(engine: WebGPUEngine, buffers: ClothBuffers): ClothPipelines {
// Helper for integrating & velocity
const createBasicShader = (name: string, source: string) => {
const cs = new ComputeShader(name, engine, { computeSource: source }, {
bindingsMapping: {
"p": { group: 0, binding: 0 },
"positions": { group: 0, binding: 1 },
"prev_positions": { group: 0, binding: 2 },
"velocities": { group: 0, binding: 3 }
}
});
cs.setStorageBuffer("p", buffers.params);
cs.setStorageBuffer("positions", buffers.positions);
cs.setStorageBuffer("prev_positions", buffers.prevPositions);
cs.setStorageBuffer("velocities", buffers.velocities);
return cs;
};
// Helper for solvers
const createSolverShader = (name: string, constraintBuffer: StorageBuffer) => {
const cs = new ComputeShader(name, engine, { computeSource: CLOTH_SOLVE_COMPUTE_WGSL }, {
bindingsMapping: {
"p": { group: 0, binding: 0 },
"positions": { group: 0, binding: 1 },
"constraints": { group: 0, binding: 2 }
}
});
cs.setStorageBuffer("p", buffers.params);
cs.setStorageBuffer("positions", buffers.positions);
cs.setStorageBuffer("constraints", constraintBuffer);
return cs;
};
return {
integrate: createBasicShader("integrate", CLOTH_INTEGRATE_COMPUTE_WGSL),
solvers: buffers.constraints.map((cBuffer, i) => createSolverShader(`solve${i}`, cBuffer)),
velocity: createBasicShader("velocity", CLOTH_VELOCITY_COMPUTE_WGSL)
};
}
// ========================================================================
// 5. RENDERING SETUP
// ========================================================================
private setupRenderMesh(scene: Scene, config: ClothConfig, positionsBuffer: StorageBuffer): void {
if (this.clothMesh) {
scene.removeMesh(this.clothMesh);
}
this.clothMesh = MeshBuilder.CreateGround("cloth", { width: 10, height: 10, subdivisions: config.gridWidth - 1 }, scene);
const clothMaterial = new ShaderMaterial("clothMat", scene, {
vertexSource: CLOTH_VERTEX_SHADER_WGSL,
fragmentSource: CLOTH_FRAGMENT_SHADER_WGSL
}, {
attributes: ["position", "uv"],
uniforms: ["viewProjection"],
storageBuffers: ["positions"],
shaderLanguage: ShaderLanguage.WGSL
});
clothMaterial.backFaceCulling = false;
clothMaterial.setStorageBuffer("positions", positionsBuffer);
this.clothMesh.material = clothMaterial;
const camera = scene.activeCamera as ArcRotateCamera;
if (camera) {
camera.alpha = Math.PI / 4;
camera.beta = Math.PI / 2.5;
camera.radius = 15;
}
}
// ========================================================================
// 6. RENDER LOOP
// ========================================================================
private startRenderLoop(engine: WebGPUEngine, scene: Scene, config: ClothConfig, buffers: ClothBuffers, pipelines: ClothPipelines): void {
const paramsData = new Float32Array(9);
// Pre-calculate constraint dispatch sizes for the 4 phases
const constraintsLength = buffers.constraints.map(b => (b as any)._buffer.capacity / 4 / 4); // Elements / vec4 length
const dispatchXConstraints = constraintsLength.map(len => Math.ceil(len / 64));
const dispatchXVertices = Math.ceil(config.numVertices / 64);
const substeps = 15;
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
this.simulationTime += engine.getDeltaTime() / 1000.0;
// Update Physics Parameters
const windX = this.isWindActive ? 5.0 : 0.0;
const windY = 0.0;
const windZ = this.isWindActive ? 15.0 : 0.0;
// Logarithmic compliance: stiffness=1 → very soft fabric, stiffness=100 → rigid metal sheet.
// alpha = compliance / dt² must be >> wSum (≈800) to be soft, << wSum to be rigid.
const softCompliance = 10.0;
const rigidCompliance = 0.00001;
const t = (this.stiffness - 1) / 99.0;
const compliance = softCompliance * Math.pow(rigidCompliance / softCompliance, t);
paramsData[0] = 0.016; // dt
paramsData[1] = -9.81; // gravity
paramsData[2] = compliance;
paramsData[3] = config.numVertices;
paramsData[4] = windX;
paramsData[5] = windY;
paramsData[6] = windZ;
paramsData[7] = this.simulationTime;
paramsData[8] = this.elongation;
buffers.params.update(paramsData);
// 1. Predict positions
pipelines.integrate.dispatch(dispatchXVertices, 1, 1);
// 2. XPBD Solver (Substeps) - Graph Coloring Phase
for (let i = 0; i < substeps; i++) {
for (let phase = 0; phase < pipelines.solvers.length; phase++) {
pipelines.solvers[phase].dispatch(dispatchXConstraints[phase], 1, 1);
}
}
// 3. Update velocities
pipelines.velocity.dispatch(dispatchXVertices, 1, 1);
});
}
} }

View File

@@ -1,6 +1,3 @@
// --- SIMULATION CONFIGURATION ---
import {ComputeShader, StorageBuffer} from '@babylonjs/core';
export interface ClothConfig { export interface ClothConfig {
gridWidth: number; gridWidth: number;
gridHeight: number; gridHeight: number;
@@ -10,27 +7,10 @@ export interface ClothConfig {
particleInvMass: number; particleInvMass: number;
} }
// --- RAW CPU DATA ---
export interface ClothData { export interface ClothData {
positions: Float32Array; positions: Float32Array;
prevPositions: Float32Array; prevPositions: Float32Array;
velocities: Float32Array; velocities: Float32Array;
constraints: number[][]; // Array containing the 4 phases constraints: number[][];
params: Float32Array; params: Float32Array;
} }
// --- WEBGPU BUFFERS ---
export interface ClothBuffers {
positions: StorageBuffer;
prevPositions: StorageBuffer;
velocities: StorageBuffer;
params: StorageBuffer;
constraints: StorageBuffer[]; // 4 phase buffers
}
// --- COMPUTE PIPELINES ---
export interface ClothPipelines {
integrate: ComputeShader;
solvers: ComputeShader[]; // 4 solve shaders
velocity: ComputeShader;
}

View File

@@ -0,0 +1,149 @@
/**
* CPU-side cloth physics mirroring the WGSL compute shaders.
* All data uses the same Float32Array vec4 layout: [x, y, z, invMass] per vertex.
*/
export interface ClothPhysicsParams {
dt: number;
gravityY: number;
compliance: number;
numVertices: number;
windX: number;
windY: number;
windZ: number;
time: number;
elongation: number;
}
/**
* Mirrors CLOTH_INTEGRATE_COMPUTE_WGSL:
* Applies gravity and wind forces, predicts new positions.
*/
export function integratePositions(
positions: Float32Array,
prevPositions: Float32Array,
velocities: Float32Array,
params: ClothPhysicsParams
): void {
for (let idx = 0; idx < params.numVertices; idx++) {
const base = idx * 4;
const invMass = positions[base + 3];
if (invMass <= 0.0) {
continue;
}
velocities[base + 1] += params.gravityY * params.dt;
const posX = positions[base + 0];
const posY = positions[base + 1];
const flutter = Math.sin(posX * 2.0 + params.time * 5.0) * Math.cos(posY * 2.0 + params.time * 3.0);
const windForceX = params.windX + (flutter * params.windX * 0.8);
const windForceY = params.windY + (flutter * 2.0);
const windForceZ = params.windZ + (flutter * params.windZ * 0.8);
velocities[base + 0] += windForceX * params.dt;
velocities[base + 1] += windForceY * params.dt;
velocities[base + 2] += windForceZ * params.dt;
prevPositions[base + 0] = positions[base + 0];
prevPositions[base + 1] = positions[base + 1];
prevPositions[base + 2] = positions[base + 2];
prevPositions[base + 3] = positions[base + 3];
positions[base + 0] += velocities[base + 0] * params.dt;
positions[base + 1] += velocities[base + 1] * params.dt;
positions[base + 2] += velocities[base + 2] * params.dt;
}
}
/**
* Mirrors CLOTH_SOLVE_COMPUTE_WGSL:
* XPBD constraint solving for one phase of constraints.
* Each constraint is stored as [idA, idB, restLength, type] (4 floats).
*/
export function solveConstraints(
positions: Float32Array,
constraints: Float32Array,
params: ClothPhysicsParams
): void {
const numConstraints = constraints.length / 4;
for (let idx = 0; idx < numConstraints; idx++) {
const cBase = idx * 4;
const constraintType = constraints[cBase + 3];
if (constraintType < 0.5) {
continue;
}
const idA = constraints[cBase + 0];
const idB = constraints[cBase + 1];
const restLength = constraints[cBase + 2] * params.elongation;
const baseA = idA * 4;
const baseB = idB * 4;
const wA = positions[baseA + 3];
const wB = positions[baseB + 3];
const wSum = wA + wB;
if (wSum <= 0.0) {
continue;
}
const dirX = positions[baseA + 0] - positions[baseB + 0];
const dirY = positions[baseA + 1] - positions[baseB + 1];
const dirZ = positions[baseA + 2] - positions[baseB + 2];
const dist = Math.sqrt(dirX * dirX + dirY * dirY + dirZ * dirZ);
if (dist < 0.0001) {
continue;
}
const nX = dirX / dist;
const nY = dirY / dist;
const nZ = dirZ / dist;
const c = dist - restLength;
const alpha = params.compliance / (params.dt * params.dt);
const lambda = -c / (wSum + alpha);
if (wA > 0.0) {
positions[baseA + 0] += nX * (lambda * wA);
positions[baseA + 1] += nY * (lambda * wA);
positions[baseA + 2] += nZ * (lambda * wA);
}
if (wB > 0.0) {
positions[baseB + 0] += nX * (-lambda * wB);
positions[baseB + 1] += nY * (-lambda * wB);
positions[baseB + 2] += nZ * (-lambda * wB);
}
}
}
/**
* Mirrors CLOTH_VELOCITY_COMPUTE_WGSL:
* Derives velocity from position changes: v = (pos - prevPos) / dt
*/
export function updateVelocities(
positions: Float32Array,
prevPositions: Float32Array,
velocities: Float32Array,
params: ClothPhysicsParams
): void {
for (let idx = 0; idx < params.numVertices; idx++) {
const base = idx * 4;
const invMass = positions[base + 3];
if (invMass <= 0.0) {
continue;
}
velocities[base + 0] = (positions[base + 0] - prevPositions[base + 0]) / params.dt;
velocities[base + 1] = (positions[base + 1] - prevPositions[base + 1]) / params.dt;
velocities[base + 2] = (positions[base + 2] - prevPositions[base + 2]) / params.dt;
}
}

View File

@@ -0,0 +1,216 @@
import {ArcRotateCamera, Engine, GroundMesh, MeshBuilder, Scene, ShaderMaterial, VertexBuffer, WebGPUEngine} from '@babylonjs/core';
import {ClothConfig, ClothData} 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';
export class ClothCpuStrategy implements ClothSimulationStrategy {
private clothMesh: GroundMesh | null = null;
private scene: Scene | null = null;
private config: ClothConfig | null = null;
private positions!: Float32Array;
private prevPositions!: Float32Array;
private velocities!: Float32Array;
private constraintPhases!: Float32Array[];
private physicsParams: ClothPhysicsParams = {
dt: 0.016,
gravityY: -9.81,
compliance: 0.00001,
numVertices: 0,
windX: 0,
windY: 0,
windZ: 0,
time: 0,
elongation: 1.0
};
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void {
this.scene = scene;
this.config = config;
this.physicsParams.numVertices = config.numVertices;
const clothData = this.generateClothData(config);
this.positions = clothData.positions;
this.prevPositions = clothData.prevPositions;
this.velocities = clothData.velocities;
this.constraintPhases = clothData.constraints.map(c => new Float32Array(c));
this.setupRenderMesh(scene, config);
this.startRenderLoop(scene, config);
}
updateParams(params: ClothSimulationParams): void {
const windX = params.isWindActive ? 5.0 : 0.0;
const windZ = params.isWindActive ? 15.0 : 0.0;
const softCompliance = 10.0;
const rigidCompliance = 0.00001;
const t = (params.stiffness - 1) / 99.0;
this.physicsParams.compliance = softCompliance * Math.pow(rigidCompliance / softCompliance, t);
this.physicsParams.windX = windX;
this.physicsParams.windY = 0.0;
this.physicsParams.windZ = windZ;
this.physicsParams.time = params.simulationTime;
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);
const velocitiesData = new Float32Array(config.numVertices * 4);
const constraintsP0: number[] = [];
const constraintsP1: number[] = [];
const constraintsP2: number[] = [];
const constraintsP3: number[] = [];
const addHorizontalConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, config.spacing, 1.0);
};
const addVerticalConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, config.spacing, 2.0);
};
for (let y = 0; y < config.gridHeight; y++) {
for (let x = 0; x < config.gridWidth; x++) {
const idx = (y * config.gridWidth + x) * 4;
positionsData[idx + 0] = (x - config.gridWidth / 2) * config.spacing;
positionsData[idx + 1] = 5.0 - (y * config.spacing);
positionsData[idx + 2] = 0.0;
positionsData[idx + 3] = (y === 0) ? 0.0 : config.particleInvMass;
prevPositionsData[idx + 0] = positionsData[idx + 0];
prevPositionsData[idx + 1] = positionsData[idx + 1];
prevPositionsData[idx + 2] = positionsData[idx + 2];
prevPositionsData[idx + 3] = positionsData[idx + 3];
}
}
for (let y = 0; y < config.gridHeight; y++) {
for (let x = 0; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1); }
for (let x = 1; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1); }
}
for (let y = 0; y < config.gridHeight - 1; y += 2) {
for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); }
}
for (let y = 1; y < config.gridHeight - 1; y += 2) {
for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); }
}
const constraintsP4: number[] = [];
const constraintsP5: number[] = [];
const constraintsP6: number[] = [];
const constraintsP7: number[] = [];
const diagSpacing = config.spacing * Math.SQRT2;
const addDiagConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, diagSpacing, 1.0);
};
for (let y = 0; y < config.gridHeight - 1; y++) {
const arr = (y % 2 === 0) ? constraintsP4 : constraintsP5;
for (let x = 0; x < config.gridWidth - 1; x++) {
addDiagConstraint(arr, y * config.gridWidth + x, (y + 1) * config.gridWidth + (x + 1));
}
}
for (let y = 0; y < config.gridHeight - 1; y++) {
const arr = (y % 2 === 0) ? constraintsP6 : constraintsP7;
for (let x = 0; x < config.gridWidth - 1; x++) {
addDiagConstraint(arr, y * config.gridWidth + (x + 1), (y + 1) * config.gridWidth + x);
}
}
return {
positions: positionsData,
prevPositions: prevPositionsData,
velocities: velocitiesData,
constraints: [
constraintsP0, constraintsP1, constraintsP2, constraintsP3,
constraintsP4, constraintsP5, constraintsP6, constraintsP7
],
params: new Float32Array(9)
};
}
private setupRenderMesh(scene: Scene, config: ClothConfig): void {
if (this.clothMesh) {
scene.removeMesh(this.clothMesh);
}
this.clothMesh = MeshBuilder.CreateGround("cloth", {
width: 10,
height: 10,
subdivisions: config.gridWidth - 1,
updatable: true
}, scene);
const clothMaterial = new ShaderMaterial("clothMat", scene, {
vertexSource: CLOTH_VERTEX_SHADER_GLSL,
fragmentSource: CLOTH_FRAGMENT_SHADER_GLSL
}, {
attributes: ["position", "uv"],
uniforms: ["viewProjection"]
});
clothMaterial.backFaceCulling = false;
this.clothMesh.material = clothMaterial;
const camera = scene.activeCamera as ArcRotateCamera;
if (camera) {
camera.alpha = Math.PI / 4;
camera.beta = Math.PI / 2.5;
camera.radius = 15;
}
}
/**
* Extracts xyz from the vec4 positions array into a vec3 array for mesh vertex update.
*/
private extractPositionsVec3(positions: Float32Array, numVertices: number): Float32Array {
const result = new Float32Array(numVertices * 3);
for (let i = 0; i < numVertices; i++) {
result[i * 3 + 0] = positions[i * 4 + 0];
result[i * 3 + 1] = positions[i * 4 + 1];
result[i * 3 + 2] = positions[i * 4 + 2];
}
return result;
}
private startRenderLoop(scene: Scene, config: ClothConfig): void {
const substeps = 15;
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.clothMesh) {
return;
}
integratePositions(this.positions, this.prevPositions, this.velocities, this.physicsParams);
for (let i = 0; i < substeps; i++) {
for (const phase of this.constraintPhases) {
solveConstraints(this.positions, phase, this.physicsParams);
}
}
updateVelocities(this.positions, this.prevPositions, this.velocities, this.physicsParams);
const posVec3 = this.extractPositionsVec3(this.positions, config.numVertices);
this.clothMesh.updateVerticesData(VertexBuffer.PositionKind, posVec3);
});
}
}

View File

@@ -0,0 +1,275 @@
import {ArcRotateCamera, ComputeShader, Engine, GroundMesh, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
import {ClothConfig, ClothData} from '../cloth.model';
import {
CLOTH_FRAGMENT_SHADER_WGSL,
CLOTH_INTEGRATE_COMPUTE_WGSL,
CLOTH_SOLVE_COMPUTE_WGSL,
CLOTH_VELOCITY_COMPUTE_WGSL,
CLOTH_VERTEX_SHADER_WGSL
} from '../cloth.shader';
import {ClothSimulationParams, ClothSimulationStrategy} from './cloth-simulation.strategy';
interface GpuBuffers {
positions: StorageBuffer;
prevPositions: StorageBuffer;
velocities: StorageBuffer;
params: StorageBuffer;
constraints: StorageBuffer[];
}
interface GpuPipelines {
integrate: ComputeShader;
solvers: ComputeShader[];
velocity: ComputeShader;
}
export class ClothGpuStrategy implements ClothSimulationStrategy {
private clothMesh: GroundMesh | null = null;
private scene: Scene | null = null;
private paramsData = new Float32Array(9);
private buffers: GpuBuffers | null = null;
private pipelines: GpuPipelines | null = null;
private dispatchXConstraints: number[] = [];
private dispatchXVertices = 0;
private numVertices = 0;
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void {
this.scene = scene;
this.numVertices = config.numVertices;
const gpuEngine = engine as WebGPUEngine;
const clothData = this.generateClothData(config);
this.buffers = this.createStorageBuffers(gpuEngine, clothData);
this.pipelines = this.setupComputePipelines(gpuEngine, this.buffers);
this.setupRenderMesh(scene, config, this.buffers.positions);
this.setupDispatchSizes(config, this.buffers);
this.startRenderLoop(scene);
}
updateParams(params: ClothSimulationParams): void {
if (!this.buffers) {
return;
}
const windX = params.isWindActive ? 5.0 : 0.0;
const windZ = params.isWindActive ? 15.0 : 0.0;
const softCompliance = 10.0;
const rigidCompliance = 0.00001;
const t = (params.stiffness - 1) / 99.0;
const compliance = softCompliance * Math.pow(rigidCompliance / softCompliance, t);
this.paramsData[0] = 0.016;
this.paramsData[1] = -9.81;
this.paramsData[2] = compliance;
this.paramsData[3] = this.numVertices;
this.paramsData[4] = windX;
this.paramsData[5] = 0.0;
this.paramsData[6] = windZ;
this.paramsData[7] = params.simulationTime;
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);
const velocitiesData = new Float32Array(config.numVertices * 4);
const constraintsP0: number[] = [];
const constraintsP1: number[] = [];
const constraintsP2: number[] = [];
const constraintsP3: number[] = [];
const addHorizontalConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, config.spacing, 1.0);
};
const addVerticalConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, config.spacing, 2.0);
};
for (let y = 0; y < config.gridHeight; y++) {
for (let x = 0; x < config.gridWidth; x++) {
const idx = (y * config.gridWidth + x) * 4;
positionsData[idx + 0] = (x - config.gridWidth / 2) * config.spacing;
positionsData[idx + 1] = 5.0 - (y * config.spacing);
positionsData[idx + 2] = 0.0;
positionsData[idx + 3] = (y === 0) ? 0.0 : config.particleInvMass;
prevPositionsData[idx + 0] = positionsData[idx + 0];
prevPositionsData[idx + 1] = positionsData[idx + 1];
prevPositionsData[idx + 2] = positionsData[idx + 2];
prevPositionsData[idx + 3] = positionsData[idx + 3];
}
}
for (let y = 0; y < config.gridHeight; y++) {
for (let x = 0; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP0, y * config.gridWidth + x, y * config.gridWidth + x + 1); }
for (let x = 1; x < config.gridWidth - 1; x += 2) { addHorizontalConstraint(constraintsP1, y * config.gridWidth + x, y * config.gridWidth + x + 1); }
}
for (let y = 0; y < config.gridHeight - 1; y += 2) {
for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP2, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); }
}
for (let y = 1; y < config.gridHeight - 1; y += 2) {
for (let x = 0; x < config.gridWidth; x++) { addVerticalConstraint(constraintsP3, y * config.gridWidth + x, (y + 1) * config.gridWidth + x); }
}
const constraintsP4: number[] = [];
const constraintsP5: number[] = [];
const constraintsP6: number[] = [];
const constraintsP7: number[] = [];
const diagSpacing = config.spacing * Math.SQRT2;
const addDiagConstraint = (arr: number[], a: number, b: number): void => {
arr.push(a, b, diagSpacing, 1.0);
};
for (let y = 0; y < config.gridHeight - 1; y++) {
const arr = (y % 2 === 0) ? constraintsP4 : constraintsP5;
for (let x = 0; x < config.gridWidth - 1; x++) {
addDiagConstraint(arr, y * config.gridWidth + x, (y + 1) * config.gridWidth + (x + 1));
}
}
for (let y = 0; y < config.gridHeight - 1; y++) {
const arr = (y % 2 === 0) ? constraintsP6 : constraintsP7;
for (let x = 0; x < config.gridWidth - 1; x++) {
addDiagConstraint(arr, y * config.gridWidth + (x + 1), (y + 1) * config.gridWidth + x);
}
}
return {
positions: positionsData,
prevPositions: prevPositionsData,
velocities: velocitiesData,
constraints: [
constraintsP0, constraintsP1, constraintsP2, constraintsP3,
constraintsP4, constraintsP5, constraintsP6, constraintsP7
],
params: new Float32Array(9)
};
}
private createStorageBuffers(engine: WebGPUEngine, data: ClothData): GpuBuffers {
const createBuffer = (arrayData: Float32Array | number[]): StorageBuffer => {
const buffer = new StorageBuffer(engine, arrayData.length * 4);
buffer.update(arrayData instanceof Float32Array ? arrayData : new Float32Array(arrayData));
return buffer;
};
return {
positions: createBuffer(data.positions),
prevPositions: createBuffer(data.prevPositions),
velocities: createBuffer(data.velocities),
params: createBuffer(data.params),
constraints: data.constraints.map(cData => createBuffer(cData))
};
}
private setupComputePipelines(engine: WebGPUEngine, buffers: GpuBuffers): GpuPipelines {
const createBasicShader = (name: string, source: string) => {
const cs = new ComputeShader(name, engine, {computeSource: source}, {
bindingsMapping: {
"p": {group: 0, binding: 0},
"positions": {group: 0, binding: 1},
"prev_positions": {group: 0, binding: 2},
"velocities": {group: 0, binding: 3}
}
});
cs.setStorageBuffer("p", buffers.params);
cs.setStorageBuffer("positions", buffers.positions);
cs.setStorageBuffer("prev_positions", buffers.prevPositions);
cs.setStorageBuffer("velocities", buffers.velocities);
return cs;
};
const createSolverShader = (name: string, constraintBuffer: StorageBuffer) => {
const cs = new ComputeShader(name, engine, {computeSource: CLOTH_SOLVE_COMPUTE_WGSL}, {
bindingsMapping: {
"p": {group: 0, binding: 0},
"positions": {group: 0, binding: 1},
"constraints": {group: 0, binding: 2}
}
});
cs.setStorageBuffer("p", buffers.params);
cs.setStorageBuffer("positions", buffers.positions);
cs.setStorageBuffer("constraints", constraintBuffer);
return cs;
};
return {
integrate: createBasicShader("integrate", CLOTH_INTEGRATE_COMPUTE_WGSL),
solvers: buffers.constraints.map((cBuffer, i) => createSolverShader(`solve${i}`, cBuffer)),
velocity: createBasicShader("velocity", CLOTH_VELOCITY_COMPUTE_WGSL)
};
}
private setupRenderMesh(scene: Scene, config: ClothConfig, positionsBuffer: StorageBuffer): void {
if (this.clothMesh) {
scene.removeMesh(this.clothMesh);
}
this.clothMesh = MeshBuilder.CreateGround("cloth", {width: 10, height: 10, subdivisions: config.gridWidth - 1}, scene);
const clothMaterial = new ShaderMaterial("clothMat", scene, {
vertexSource: CLOTH_VERTEX_SHADER_WGSL,
fragmentSource: CLOTH_FRAGMENT_SHADER_WGSL
}, {
attributes: ["position", "uv"],
uniforms: ["viewProjection"],
storageBuffers: ["positions"],
shaderLanguage: ShaderLanguage.WGSL
});
clothMaterial.backFaceCulling = false;
clothMaterial.setStorageBuffer("positions", positionsBuffer);
this.clothMesh.material = clothMaterial;
const camera = scene.activeCamera as ArcRotateCamera;
if (camera) {
camera.alpha = Math.PI / 4;
camera.beta = Math.PI / 2.5;
camera.radius = 15;
}
}
private setupDispatchSizes(config: ClothConfig, buffers: GpuBuffers): void {
const constraintsLength = buffers.constraints.map(b => (b as any)._buffer.capacity / 4 / 4);
this.dispatchXConstraints = constraintsLength.map(len => Math.ceil(len / 64));
this.dispatchXVertices = Math.ceil(config.numVertices / 64);
}
private startRenderLoop(scene: Scene): void {
const substeps = 15;
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.buffers || !this.pipelines) {
return;
}
this.buffers.params.update(this.paramsData);
this.pipelines.integrate.dispatch(this.dispatchXVertices, 1, 1);
for (let i = 0; i < substeps; i++) {
for (let phase = 0; phase < this.pipelines.solvers.length; phase++) {
this.pipelines.solvers[phase].dispatch(this.dispatchXConstraints[phase], 1, 1);
}
}
this.pipelines.velocity.dispatch(this.dispatchXVertices, 1, 1);
});
}
}

View File

@@ -0,0 +1,17 @@
import {Engine, GroundMesh, Scene, WebGPUEngine} from '@babylonjs/core';
import {ClothConfig} from '../cloth.model';
export interface ClothSimulationParams {
stiffness: number;
elongation: number;
isWindActive: boolean;
simulationTime: number;
deltaTime: number;
}
export interface ClothSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine, config: ClothConfig): void;
updateParams(params: ClothSimulationParams): void;
getMesh(): GroundMesh | null;
dispose(): void;
}

View File

@@ -1,11 +1,11 @@
<mat-card class="algo-container"> <mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header> <mat-card-header>
<mat-card-title>{{ 'GOL.TITLE' | translate }}</mat-card-title> <mat-card-title>{{ 'GOL.TITLE' | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<app-information [algorithmInformation]="algoInformation"/> <app-information [algorithmInformation]="algoInformation"/>
<div class="controls-container"> <div class="flex flex-col mb-4">
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button mat-raised-button (click)="generate(Scenario.SIMPLE)"> <button mat-raised-button (click)="generate(Scenario.SIMPLE)">
<mat-icon>arrow_right</mat-icon> {{ 'GOL.SIMPLE_SCENE' | translate }} <mat-icon>arrow_right</mat-icon> {{ 'GOL.SIMPLE_SCENE' | translate }}
</button> </button>
@@ -22,7 +22,7 @@
<mat-icon>check_box_outline_blank</mat-icon> {{ 'GOL.EMPTY_SCENE' | translate }} <mat-icon>check_box_outline_blank</mat-icon> {{ 'GOL.EMPTY_SCENE' | translate }}
</button> </button>
</div> </div>
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
@if (gameStarted()) @if (gameStarted())
{ {
<button mat-raised-button (click)="pauseGame()"> <button mat-raised-button (click)="pauseGame()">
@@ -35,8 +35,8 @@
} }
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p> <p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
</div> </div>
<div class="input-container"> <div class="flex gap-3 items-center flex-wrap">
<mat-form-field appearance="outline" class="input-field"> <mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label> <mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
<input <input
matInput matInput
@@ -47,7 +47,7 @@
(ngModelChange)="pauseGame(); genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()" (ngModelChange)="pauseGame(); genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
/> />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="input-field"> <mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label> <mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
<input <input
matInput matInput
@@ -58,7 +58,7 @@
(ngModelChange)="pauseGame(); genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()" (ngModelChange)="pauseGame(); genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
/> />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="input-field"> <mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'GOL.SPEED' | translate }}</mat-label> <mat-label>{{ 'GOL.SPEED' | translate }}</mat-label>
<input <input
matInput matInput
@@ -72,9 +72,9 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div class="legend"> <div class="flex flex-wrap gap-4 items-center text-[0.9em]">
<span><span class="legend-color alive"></span> {{ 'GOL.ALIVE' | translate }}</span> <span><span class="legend-swatch bg-black"></span> {{ 'GOL.ALIVE' | translate }}</span>
<span><span class="legend-color empty"></span> {{ 'GOL.DEAD' | translate }}</span> <span><span class="legend-swatch bg-gray-300"></span> {{ 'GOL.DEAD' | translate }}</span>
</div> </div>
</div> </div>
<app-generic-grid <app-generic-grid

View File

@@ -1,20 +1,20 @@
<mat-card class="algo-container"> <mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header> <mat-card-header>
<mat-card-title>{{ 'FOUR_COLOR.TITLE' | translate }}</mat-card-title> <mat-card-title>{{ 'FOUR_COLOR.TITLE' | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<app-information [algorithmInformation]="algoInformation"/> <app-information [algorithmInformation]="algoInformation"/>
<div class="controls-container"> <div class="flex flex-col mb-4">
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button mat-flat-button color="primary" (click)="generateNewMap()">{{ 'FOUR_COLOR.GENERATE' | translate }}</button> <button mat-flat-button color="primary" (click)="generateNewMap()">{{ 'FOUR_COLOR.GENERATE' | translate }}</button>
<button mat-flat-button color="accent" (click)="autoSolve()">{{ 'FOUR_COLOR.SOLVE' | translate }}</button> <button mat-flat-button color="accent" (click)="autoSolve()">{{ 'FOUR_COLOR.SOLVE' | translate }}</button>
<button mat-stroked-button (click)="resetColors()">{{ 'FOUR_COLOR.CLEAR' | translate }}</button> <button mat-stroked-button (click)="resetColors()">{{ 'FOUR_COLOR.CLEAR' | translate }}</button>
</div> </div>
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<div class="input-container"> <div class="flex gap-3 items-center flex-wrap">
<mat-form-field appearance="outline" class="input-field"> <mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label> <mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
<input <input
matInput matInput
@@ -26,7 +26,7 @@
/> />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="input-field"> <mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label> <mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
<input <input
matInput matInput
@@ -40,21 +40,28 @@
</div> </div>
</div> </div>
<div class="legend"> <div class="flex flex-wrap gap-4 items-center text-[0.9em]">
<span><span class="legend-color color1"></span> {{ 'FOUR_COLOR.COLOR_1' | translate }}</span> <span><span class="legend-swatch bg-[#FF5252]"></span> {{ 'FOUR_COLOR.COLOR_1' | translate }}</span>
<span><span class="legend-color color2"></span> {{ 'FOUR_COLOR.COLOR_2' | translate }}</span> <span><span class="legend-swatch bg-[#448AFF]"></span> {{ 'FOUR_COLOR.COLOR_2' | translate }}</span>
<span><span class="legend-color color3"></span> {{ 'FOUR_COLOR.COLOR_3' | translate }}</span> <span><span class="legend-swatch bg-[#4CAF50]"></span> {{ 'FOUR_COLOR.COLOR_3' | translate }}</span>
<span><span class="legend-color color4"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span> <span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
</div> </div>
<div class="status-panel" [ngClass]="solutionStatus.toLowerCase()"> <div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded border-l-[5px] font-medium min-w-[300px] items-center"
<span class="status-label">{{ 'FOUR_COLOR.STATUS.LABEL' | translate }}:</span> [ngClass]="{
<span class="status-message">{{ 'FOUR_COLOR.STATUS.' + solutionStatus | translate }}</span> 'border-l-gray-500 bg-app-bg': solutionStatus === 'INCOMPLETE',
'border-l-green-500 bg-green-50 text-green-800': solutionStatus === 'SOLVED',
'border-l-orange-500 bg-orange-50 text-orange-700': solutionStatus === 'CONFLICTS',
'border-l-red-500 bg-red-50 text-red-800': solutionStatus === 'INVALID'
}">
<span class="uppercase text-[0.8em] opacity-70">{{ 'FOUR_COLOR.STATUS.LABEL' | translate }}:</span>
<span>{{ 'FOUR_COLOR.STATUS.' + solutionStatus | translate }}</span>
</div> </div>
</div> </div>
<div class="canvas-container"> <div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
<canvas #fourColorCanvas <canvas #fourColorCanvas
class="cursor-pointer max-w-full h-auto [image-rendering:pixelated]"
(mousedown)="onMouseDown($event)" (mousedown)="onMouseDown($event)"
(mousemove)="onMouseMove($event)" (mousemove)="onMouseMove($event)"
(touchstart)="onTouchStart($event)" (touchstart)="onTouchStart($event)"

View File

@@ -1,54 +0,0 @@
.status-panel {
margin-top: 20px;
display: flex;
gap: 10px;
padding: 10px 15px;
border-radius: 4px;
background-color: var(--app-fg);
border-left: 5px solid #9e9e9e;
font-weight: 500;
min-width: 300px;
align-items: center;
}
.status-panel.incomplete {
border-left-color: #9e9e9e;
background-color: var(--app-bg);
}
.status-panel.solved {
border-left-color: #4CAF50;
background-color: #e8f5e9;
color: #2e7d32;
}
.status-panel.conflicts {
border-left-color: #ff9800;
background-color: #fff3e0;
color: #ef6c00;
}
.status-panel.invalid {
border-left-color: #f44336;
background-color: #ffebee;
color: #c62828;
}
.status-label {
text-transform: uppercase;
font-size: 0.8em;
opacity: 0.7;
}
.color1 { background-color: #FF5252; }
.color2 { background-color: #448AFF; }
.color3 { background-color: #4CAF50; }
.color4 { background-color: #FFEB3B; }
canvas {
cursor: pointer;
max-width: 100%;
height: auto;
image-rendering: pixelated;
}

View File

@@ -1,12 +1,12 @@
<mat-card class="algo-container"> <mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header> <mat-card-header>
<mat-card-title>{{ 'FRACTAL.TITLE' | translate }}</mat-card-title> <mat-card-title>{{ 'FRACTAL.TITLE' | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<app-information [algorithmInformation]="algoInformation"/> <app-information [algorithmInformation]="algoInformation"/>
<div class="controls-container"> <div class="flex flex-col mb-4">
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<mat-form-field appearance="fill"> <mat-form-field class="w-[200px]" appearance="fill">
<mat-label>{{ 'FRACTAL.ALGORITHM' | translate }}</mat-label> <mat-label>{{ 'FRACTAL.ALGORITHM' | translate }}</mat-label>
<mat-select [value]="'Mandelbrot'" (selectionChange)="onAlgorithmChange($event.value)"> <mat-select [value]="'Mandelbrot'" (selectionChange)="onAlgorithmChange($event.value)">
<mat-option value="Mandelbrot">Mandelbrot</mat-option> <mat-option value="Mandelbrot">Mandelbrot</mat-option>
@@ -15,7 +15,7 @@
<mat-option value="Newton">Newton</mat-option> <mat-option value="Newton">Newton</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="fill"> <mat-form-field class="w-[200px]" appearance="fill">
<mat-label>{{ 'FRACTAL.COLOR_SCHEME' | translate }}</mat-label> <mat-label>{{ 'FRACTAL.COLOR_SCHEME' | translate }}</mat-label>
<mat-select [value]="'Blue-Gold'" (selectionChange)="onColorChanged($event.value)"> <mat-select [value]="'Blue-Gold'" (selectionChange)="onColorChanged($event.value)">
<mat-option value="Blue-Gold">Blue-Gold</mat-option> <mat-option value="Blue-Gold">Blue-Gold</mat-option>
@@ -29,7 +29,7 @@
<mat-icon>undo</mat-icon> {{ 'FRACTAL.RESET' | translate }} <mat-icon>undo</mat-icon> {{ 'FRACTAL.RESET' | translate }}
</button> </button>
</div> </div>
<div class="zoom-controls" style="display: flex; align-items: center; gap: 10px;"> <div style="display: flex; align-items: center; gap: 10px;">
<mat-icon>zoom_out</mat-icon> <mat-icon>zoom_out</mat-icon>
<ngx-slider [(value)]="sliderValue" [options]="options" (valueChange)="onSliderChange($event)" ></ngx-slider> <ngx-slider [(value)]="sliderValue" [options]="options" (valueChange)="onSliderChange($event)" ></ngx-slider>
<mat-icon>zoom_in</mat-icon> <mat-icon>zoom_in</mat-icon>

View File

@@ -1,11 +1,11 @@
<mat-card class="algo-container"> <mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header> <mat-card-header>
<mat-card-title>{{ 'FRACTAL3D.TITLE' | translate }}</mat-card-title> <mat-card-title>{{ 'FRACTAL3D.TITLE' | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<app-information [algorithmInformation]="algoInformation"/> <app-information [algorithmInformation]="algoInformation"/>
<div class="controls-container"> <div class="flex flex-col mb-4">
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button matButton="filled" (click)="onFractalTypeChange(0)">{{ 'FRACTAL3D.MANDELBULB' | translate }}</button> <button matButton="filled" (click)="onFractalTypeChange(0)">{{ 'FRACTAL3D.MANDELBULB' | translate }}</button>
<button matButton="filled" (click)="onFractalTypeChange(1)">{{ 'FRACTAL3D.MANDELBOX' | translate }}</button> <button matButton="filled" (click)="onFractalTypeChange(1)">{{ 'FRACTAL3D.MANDELBOX' | translate }}</button>
<button matButton="filled" (click)="onFractalTypeChange(2)">{{ 'FRACTAL3D.JULIA' | translate }}</button> <button matButton="filled" (click)="onFractalTypeChange(2)">{{ 'FRACTAL3D.JULIA' | translate }}</button>

View File

@@ -1,10 +1,10 @@
<div class="algo-info"> <div class="mb-4 py-3 px-4 border border-[#ddd] rounded-lg">
<h3>{{ algorithmInformation.title | translate }}</h3> <h3 class="m-0 mb-2">{{ algorithmInformation.title | translate }}</h3>
@if(algorithmInformation.entries && algorithmInformation.entries.length > 0){ @if(algorithmInformation.entries && algorithmInformation.entries.length > 0){
@for (algo of algorithmInformation.entries; track algo) @for (algo of algorithmInformation.entries; track algo)
{ {
<p> <p class="my-2">
<strong> <strong>
@if(algo.translateName){ @if(algo.translateName){
{{ algo.name | translate}} {{ algo.name | translate}}
@@ -20,7 +20,7 @@
@if (algorithmInformation.disclaimer !== '') @if (algorithmInformation.disclaimer !== '')
{ {
<p> <p class="my-2">
<strong>{{ 'ALGORITHM.NOTE' | translate}}</strong> {{ algorithmInformation.disclaimer | translate}} <strong>{{ 'ALGORITHM.NOTE' | translate}}</strong> {{ algorithmInformation.disclaimer | translate}}
</p> </p>
@if (algorithmInformation.disclaimerListEntry && algorithmInformation.disclaimerListEntry.length > 0) @if (algorithmInformation.disclaimerListEntry && algorithmInformation.disclaimerListEntry.length > 0)

View File

@@ -1,27 +1,27 @@
<mat-card class="algo-container"> <mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header> <mat-card-header>
<mat-card-title>{{ 'LABYRINTH.TITLE' | translate }}</mat-card-title> <mat-card-title>{{ 'LABYRINTH.TITLE' | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<app-information [algorithmInformation]="algoInformation"/> <app-information [algorithmInformation]="algoInformation"/>
<div class="controls-container"> <div class="flex flex-col mb-4">
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button> <button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button> <button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
</div> </div>
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="createRandom(true)">{{ 'LABYRINTH.PRIM' | translate }}</button> <button matButton="filled" [disabled]="isAnimationRunning()" (click)="createRandom(true)">{{ 'LABYRINTH.PRIM' | translate }}</button>
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="createRandom(false)">{{ 'LABYRINTH.KRUSKAL' | translate }}</button> <button matButton="filled" [disabled]="isAnimationRunning()" (click)="createRandom(false)">{{ 'LABYRINTH.KRUSKAL' | translate }}</button>
</div> </div>
<div class="legend"> <div class="flex flex-wrap gap-4 items-center text-[0.9em]">
<span><span class="legend-color start"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span> <span><span class="legend-swatch bg-green-600"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
<span><span class="legend-color end"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span> <span><span class="legend-swatch bg-red-600"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
<span><span class="legend-color wall"></span> {{ 'PATHFINDING.WALL' | translate }}</span> <span><span class="legend-swatch bg-black"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
<span><span class="legend-color visited"></span> {{ 'PATHFINDING.VISITED' | translate }}</span> <span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
<span><span class="legend-color path"></span> {{ 'PATHFINDING.PATH' | translate }}</span> <span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
</div> </div>
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p> <p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p> <p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
</div> </div>

View File

@@ -1,33 +1,33 @@
<mat-card class="algo-container"> <mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header> <mat-card-header>
<mat-card-title>{{ 'PATHFINDING.TITLE' | translate }}</mat-card-title> <mat-card-title>{{ 'PATHFINDING.TITLE' | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<app-information [algorithmInformation]="algoInformation"/> <app-information [algorithmInformation]="algoInformation"/>
<div class="controls-container"> <div class="flex flex-col mb-4">
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button matButton="filled" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button> <button matButton="filled" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
<button matButton="filled" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button> <button matButton="filled" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
</div> </div>
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'normal'})">{{ 'PATHFINDING.NORMAL_CASE' | translate }}</button> <button matButton="filled" (click)="createCase({withWalls: true, scenario: 'normal'})">{{ 'PATHFINDING.NORMAL_CASE' | translate }}</button>
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'random'})">{{ 'PATHFINDING.RANDOM_CASE' | translate }}</button> <button matButton="filled" (click)="createCase({withWalls: true, scenario: 'random'})">{{ 'PATHFINDING.RANDOM_CASE' | translate }}</button>
<button matButton="filled" (click)="createCase({withWalls: true, scenario: 'edge'})">{{ 'PATHFINDING.EDGE_CASE' | translate }}</button> <button matButton="filled" (click)="createCase({withWalls: true, scenario: 'edge'})">{{ 'PATHFINDING.EDGE_CASE' | translate }}</button>
<button matButton="filled" (click)="createCase({withWalls: false, scenario: 'normal'})">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button> <button matButton="filled" (click)="createCase({withWalls: false, scenario: 'normal'})">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button>
</div> </div>
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<mat-button-toggle-group [(ngModel)]="selectedNodeType" aria-label="Node Type Selection"> <mat-button-toggle-group class="rounded overflow-hidden" [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
<mat-button-toggle [value]="NodeType.Start">{{ 'PATHFINDING.START_NODE' | translate }}</mat-button-toggle> <mat-button-toggle [value]="NodeType.Start">{{ 'PATHFINDING.START_NODE' | translate }}</mat-button-toggle>
<mat-button-toggle [value]="NodeType.End">{{ 'PATHFINDING.END_NODE' | translate }}</mat-button-toggle> <mat-button-toggle [value]="NodeType.End">{{ 'PATHFINDING.END_NODE' | translate }}</mat-button-toggle>
<mat-button-toggle [value]="NodeType.Wall">{{ 'PATHFINDING.WALL' | translate }}</mat-button-toggle> <mat-button-toggle [value]="NodeType.Wall">{{ 'PATHFINDING.WALL' | translate }}</mat-button-toggle>
<mat-button-toggle [value]="NodeType.None">{{ 'PATHFINDING.CLEAR_NODE' | translate }}</mat-button-toggle> <mat-button-toggle [value]="NodeType.None">{{ 'PATHFINDING.CLEAR_NODE' | translate }}</mat-button-toggle>
</mat-button-toggle-group> </mat-button-toggle-group>
</div> </div>
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<div class="input-container"> <div class="flex gap-3 items-center flex-wrap">
<mat-form-field appearance="outline" class="input-field"> <mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label> <mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
<input <input
matInput matInput
@@ -38,7 +38,7 @@
(ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()" (ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
/> </mat-form-field> /> </mat-form-field>
<mat-form-field appearance="outline" class="input-field"> <mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label> <mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
<input <input
matInput matInput
@@ -51,14 +51,14 @@
</div> </div>
</div> </div>
<div class="legend"> <div class="flex flex-wrap gap-4 items-center text-[0.9em]">
<span><span class="legend-color start"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span> <span><span class="legend-swatch bg-green-600"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
<span><span class="legend-color end"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span> <span><span class="legend-swatch bg-red-600"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
<span><span class="legend-color wall"></span> {{ 'PATHFINDING.WALL' | translate }}</span> <span><span class="legend-swatch bg-black"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
<span><span class="legend-color visited"></span> {{ 'PATHFINDING.VISITED' | translate }}</span> <span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
<span><span class="legend-color path"></span> {{ 'PATHFINDING.PATH' | translate }}</span> <span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
</div> </div>
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p> <p>{{ 'PATHFINDING.PATH_LENGTH' | translate }}: {{ pathLength }}</p>
<p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p> <p>{{ 'PATHFINDING.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
</div> </div>

View File

@@ -0,0 +1,134 @@
/**
* GLSL shaders for pendulum rendering on WebGL.
* Replicates the visual output of the WGSL pendulum compute+fragment shaders.
* Uses a feedback texture (ping-pong) for trail persistence.
*
* Coordinate note: WGSL pixel buffer has Y=0 at top (screen space).
* GLSL UVs have Y=0 at bottom. We flip Y via (1.0 - uv.y) to match.
*/
export const PENDULUM_VERTEX_SHADER_GLSL = `
precision highp float;
attribute vec3 position;
attribute vec2 uv;
varying vec2 vUV;
void main() {
vUV = uv;
gl_Position = vec4(position, 1.0);
}
`;
/**
* Shader for the feedback pass: renders pendulum state to a render target texture.
* The R channel stores trail1 intensity, G stores trail2 intensity.
*/
export const PENDULUM_FEEDBACK_FRAGMENT_GLSL = `
precision highp float;
uniform vec2 resolution;
uniform float theta1;
uniform float theta2;
uniform float l1;
uniform float l2;
uniform float trailDecay;
uniform sampler2D previousFrame;
varying vec2 vUV;
void main() {
// Flip Y to match WGSL screen-space (Y=0 at top)
vec2 uv = vec2(vUV.x, 1.0 - vUV.y);
float aspect = resolution.x / resolution.y;
vec2 uvCorrected = vec2(uv.x * aspect, uv.y);
vec4 prev = texture2D(previousFrame, vUV);
float trail1 = prev.r * trailDecay;
float trail2 = prev.g * trailDecay;
vec2 origin = vec2(0.5 * aspect, 0.3);
float displayScale = 0.15;
vec2 p1 = origin + vec2(sin(theta1), cos(theta1)) * l1 * displayScale;
vec2 p2 = p1 + vec2(sin(theta2), cos(theta2)) * l2 * displayScale;
float dMass1 = length(uvCorrected - p1);
float dMass2 = length(uvCorrected - p2);
if (dMass1 < 0.02) {
trail1 = 1.0;
}
if (dMass2 < 0.02) {
trail2 = 1.0;
}
gl_FragColor = vec4(trail1, trail2, 0.0, 1.0);
}
`;
/**
* Display shader: reads trail data from feedback texture and renders final colors.
*/
export const PENDULUM_DISPLAY_FRAGMENT_GLSL = `
precision highp float;
uniform vec2 resolution;
uniform float theta1;
uniform float theta2;
uniform float l1;
uniform float l2;
uniform sampler2D trailTexture;
varying vec2 vUV;
float sdSegment(vec2 p, vec2 a, vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h);
}
void main() {
// Flip Y to match WGSL screen-space (Y=0 at top)
vec2 uv = vec2(vUV.x, 1.0 - vUV.y);
float aspect = resolution.x / resolution.y;
vec2 uvCorrected = vec2(uv.x * aspect, uv.y);
vec4 trailData = texture2D(trailTexture, vUV);
float trail1 = trailData.r;
float trail2 = trailData.g;
vec2 origin = vec2(0.5 * aspect, 0.3);
float displayScale = 0.15;
vec2 p1 = origin + vec2(sin(theta1), cos(theta1)) * l1 * displayScale;
vec2 p2 = p1 + vec2(sin(theta2), cos(theta2)) * l2 * displayScale;
float dLine1 = sdSegment(uvCorrected, origin, p1);
float dLine2 = sdSegment(uvCorrected, p1, p2);
float dMass1 = length(uvCorrected - p1);
float dMass2 = length(uvCorrected - p2);
vec3 bgColor = vec3(0.1, 0.1, 0.15);
vec3 mass1Color = vec3(1.0, 0.0, 0.0);
vec3 mass2Color = vec3(0.0, 1.0, 0.0);
vec3 line1Color = vec3(1.0, 1.0, 0.0);
vec3 line2Color = vec3(1.0, 0.0, 1.0);
vec3 finalColor = bgColor;
finalColor = mix(finalColor, mass1Color, clamp(trail1, 0.0, 1.0));
finalColor = mix(finalColor, mass2Color, clamp(trail2, 0.0, 1.0));
if (dMass1 >= 0.02 && dMass2 >= 0.02) {
if (dLine1 < 0.003) {
finalColor = line1Color;
} else if (dLine2 < 0.003) {
finalColor = line2Color;
}
}
gl_FragColor = vec4(finalColor, 1.0);
}
`;

View File

@@ -1,45 +1,45 @@
<mat-card class="algo-container"> <mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header> <mat-card-header>
<mat-card-title>{{ 'PENDULUM.TITLE' | translate }}</mat-card-title> <mat-card-title>{{ 'PENDULUM.TITLE' | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<app-information [algorithmInformation]="algoInformation"/> <app-information [algorithmInformation]="algoInformation"/>
<div class="controls-container"> <div class="flex flex-col mb-4">
<div class="sliders-grid"> <div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6">
<div class="slider-item"> <div class="flex items-center gap-4 mr-4">
<p>{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p> <p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p>
<ngx-slider [(value)]="simParams.trailDecay" [options]="trailDecayOptions" ></ngx-slider> <ngx-slider class="grow" [(value)]="simParams.trailDecay" [options]="trailDecayOptions" ></ngx-slider>
</div> </div>
<div class="slider-item"> <div class="flex items-center gap-4 mr-4">
<p>{{ 'PENDULUM.ATTRACTION' | translate }}</p> <p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.ATTRACTION' | translate }}</p>
<ngx-slider [(value)]="simParams.g" [options]="gravityOptions" ></ngx-slider> <ngx-slider class="grow" [(value)]="simParams.g" [options]="gravityOptions" ></ngx-slider>
</div> </div>
<div class="slider-item"> <div class="flex items-center gap-4 mr-4">
<p>{{ 'PENDULUM.L1_LENGTH' | translate }}</p> <p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L1_LENGTH' | translate }}</p>
<ngx-slider [(value)]="simParams.l1" [options]="lengthOptions" ></ngx-slider> <ngx-slider class="grow" [(value)]="simParams.l1" [options]="lengthOptions" ></ngx-slider>
</div> </div>
<div class="slider-item"> <div class="flex items-center gap-4 mr-4">
<p>{{ 'PENDULUM.L2_LENGTH' | translate }}</p> <p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
<ngx-slider [(value)]="simParams.l2" [options]="lengthOptions" ></ngx-slider> <ngx-slider class="grow" [(value)]="simParams.l2" [options]="lengthOptions" ></ngx-slider>
</div> </div>
<div class="slider-item"> <div class="flex items-center gap-4 mr-4">
<p>{{ 'PENDULUM.M1_MASS' | translate }}</p> <p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M1_MASS' | translate }}</p>
<ngx-slider [(value)]="simParams.m1" [options]="massOptions" ></ngx-slider> <ngx-slider class="grow" [(value)]="simParams.m1" [options]="massOptions" ></ngx-slider>
</div> </div>
<div class="slider-item"> <div class="flex items-center gap-4 mr-4">
<p>{{ 'PENDULUM.M2_MASS' | translate }}</p> <p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M2_MASS' | translate }}</p>
<ngx-slider [(value)]="simParams.m2" [options]="massOptions" ></ngx-slider> <ngx-slider class="grow" [(value)]="simParams.m2" [options]="massOptions" ></ngx-slider>
</div> </div>
<div class="slider-item full-width"> <div class="flex items-center gap-4 mr-4 col-span-full">
<p>{{ 'PENDULUM.DAMPING' | translate }}</p> <p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.DAMPING' | translate }}</p>
<ngx-slider [(value)]="simParams.damping" [options]="dampingOptions" ></ngx-slider> <ngx-slider class="grow" [(value)]="simParams.damping" [options]="dampingOptions" ></ngx-slider>
</div> </div>
</div> </div>
<div class="actions-container"> <div class="flex flex-wrap gap-3 mb-4">
<button mat-raised-button color="primary" (click)="pushPendulum(true)"> <button mat-raised-button color="primary" (click)="pushPendulum(true)">
{{ 'PENDULUM.POKE_M1' | translate }} {{ 'PENDULUM.POKE_M1' | translate }}
</button> </button>
@@ -51,11 +51,11 @@
</button> </button>
</div> </div>
<div class="legend" style="margin-top: 10px"> <div class="flex flex-wrap gap-4 items-center text-[0.9em] mt-2.5">
<span><span class="legend-color L1"></span> L1</span> <span><span class="legend-swatch bg-yellow-400"></span> L1</span>
<span><span class="legend-color L2"></span> L2</span> <span><span class="legend-swatch bg-fuchsia-500"></span> L2</span>
<span><span class="legend-color M1"></span> M1</span> <span><span class="legend-swatch bg-red-600"></span> M1</span>
<span><span class="legend-color M2"></span> M2</span> <span><span class="legend-swatch bg-green-600"></span> M2</span>
</div> </div>
</div> </div>
<app-babylon-canvas <app-babylon-canvas

View File

@@ -1,41 +0,0 @@
.sliders-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
margin-bottom: 1.5rem;
.slider-item {
display: flex;
align-items: center;
gap: 1rem;
margin-right: 1rem;
p {
width: 100px;
flex-shrink: 0;
margin: 0;
font-size: 0.9rem;
}
ngx-slider {
flex-grow: 1;
}
&.full-width {
grid-column: 1 / -1;
}
}
}
.actions-container {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 900px) {
.sliders-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
}

View File

@@ -1,8 +1,7 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component'; import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {ComputeShader, ShaderLanguage, StorageBuffer} from '@babylonjs/core';
import {PENDULUM_FRAGMENT_SHADER_WGSL, PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL, PENDULUM_RENDER_COMPUTE_SHADER_WGSL, PENDULUM_VERTEX_SHADER_WGSL} from './pendulum.shader';
import {FormsModule} from '@angular/forms'; import {FormsModule} from '@angular/forms';
import {NgxSliderModule, Options} from '@angular-slider/ngx-slider'; import {NgxSliderModule, Options} from '@angular-slider/ngx-slider';
import {DEFAULT_DAMPING, DEFAULT_G, DEFAULT_L1_LENGTH, DEFAULT_M1_MASS, DEFAULT_L2_LENGTH, DEFAULT_M2_MASS, DEFAULT_TRAIL_DECAY, MAX_DAMPING, MAX_G, MAX_LENGTH, MAX_MASS, MAX_TRAIL_DECAY, MIN_DAMPING, MIN_G, MIN_LENGTH, MIN_MASS, MIN_TRAIL_DECAY, IMPULSE_M2, IMPULSE_M1} from './pendulum.model'; import {DEFAULT_DAMPING, DEFAULT_G, DEFAULT_L1_LENGTH, DEFAULT_M1_MASS, DEFAULT_L2_LENGTH, DEFAULT_M2_MASS, DEFAULT_TRAIL_DECAY, MAX_DAMPING, MAX_G, MAX_LENGTH, MAX_MASS, MAX_TRAIL_DECAY, MIN_DAMPING, MIN_G, MIN_LENGTH, MIN_MASS, MIN_TRAIL_DECAY, IMPULSE_M2, IMPULSE_M1} from './pendulum.model';
@@ -11,6 +10,9 @@ import {MatButton} from '@angular/material/button';
import {Information} from '../information/information'; import {Information} from '../information/information';
import {AlgorithmInformation} from '../information/information.models'; import {AlgorithmInformation} from '../information/information.models';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {PendulumSimulationStrategy} from './strategies/pendulum-simulation.strategy';
import {PendulumGpuStrategy} from './strategies/pendulum-gpu.strategy';
import {PendulumCpuStrategy} from './strategies/pendulum-cpu.strategy';
@Component({ @Component({
selector: 'app-pendulum', selector: 'app-pendulum',
@@ -31,7 +33,6 @@ import {UrlConstants} from '../../../constants/UrlConstants';
}) })
class PendulumComponent { class PendulumComponent {
// --- CONFIGURATION ---
algoInformation: AlgorithmInformation = { algoInformation: AlgorithmInformation = {
title: 'PENDULUM.EXPLANATION.TITLE', title: 'PENDULUM.EXPLANATION.TITLE',
entries: [ entries: [
@@ -46,15 +47,9 @@ class PendulumComponent {
disclaimerListEntry: ['PENDULUM.EXPLANATION.DISCLAIMER_1', 'PENDULUM.EXPLANATION.DISCLAIMER_2', 'PENDULUM.EXPLANATION.DISCLAIMER_3', 'PENDULUM.EXPLANATION.DISCLAIMER_4'] disclaimerListEntry: ['PENDULUM.EXPLANATION.DISCLAIMER_1', 'PENDULUM.EXPLANATION.DISCLAIMER_2', 'PENDULUM.EXPLANATION.DISCLAIMER_3', 'PENDULUM.EXPLANATION.DISCLAIMER_4']
}; };
renderConfig: RenderConfig = { renderConfig: RenderConfig = {
mode: '2D', mode: '2D',
initialViewSize: 2, initialViewSize: 2
shaderLanguage: ShaderLanguage.WGSL,
vertexShader: PENDULUM_VERTEX_SHADER_WGSL,
fragmentShader: PENDULUM_FRAGMENT_SHADER_WGSL,
uniformNames: [],
uniformBufferNames: []
}; };
trailDecayOptions: Options = { trailDecayOptions: Options = {
@@ -107,7 +102,6 @@ class PendulumComponent {
hidePointerLabels: false hidePointerLabels: false
}; };
// Central management of physics parameters
readonly simParams = { readonly simParams = {
time: 0, time: 0,
dt: 0.015, dt: 0.015,
@@ -123,6 +117,7 @@ class PendulumComponent {
}; };
private currentSceneData: SceneEventData | null = null; private currentSceneData: SceneEventData | null = null;
private strategy: PendulumSimulationStrategy | null = null;
onSceneReady(event: SceneEventData) { onSceneReady(event: SceneEventData) {
this.currentSceneData = event; this.currentSceneData = event;
@@ -130,83 +125,34 @@ class PendulumComponent {
} }
private createSimulation() { private createSimulation() {
if (!this.currentSceneData){ if (!this.currentSceneData) {
return; return;
} }
const {engine, scene} = this.currentSceneData;
engine.resize();
const width = engine.getRenderWidth(); const {engine, scene, gpuTier} = this.currentSceneData;
const height = engine.getRenderHeight();
const totalPixels = width * height;
// --- 1. BUFFERS --- if (this.strategy) {
const pixelBuffer = new StorageBuffer(engine, totalPixels * 4); this.strategy.dispose();
const stateBuffer = new StorageBuffer(engine, 4 * 4);
stateBuffer.update(new Float32Array([Math.PI / 4, Math.PI / 2, 0, 0])); // Initial angles
const paramsBuffer = new StorageBuffer(engine, 14 * 4);
const paramsData = new Float32Array(14);
// --- 2. SHADERS ---
const csPhysics = new ComputeShader("physics", engine,
{computeSource: PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL},
{bindingsMapping: {"state": {group: 0, binding: 0}, "p": {group: 0, binding: 1}}}
);
csPhysics.setStorageBuffer("state", stateBuffer);
csPhysics.setStorageBuffer("p", paramsBuffer);
const csRender = new ComputeShader("render", engine,
{computeSource: PENDULUM_RENDER_COMPUTE_SHADER_WGSL},
{bindingsMapping: {"pixelBuffer": {group: 0, binding: 0}, "p": {group: 0, binding: 1}, "state": {group: 0, binding: 2}}}
);
csRender.setStorageBuffer("pixelBuffer", pixelBuffer);
csRender.setStorageBuffer("p", paramsBuffer);
csRender.setStorageBuffer("state", stateBuffer);
// --- 3. MATERIAL ---
const plane = scene.getMeshByName("plane");
if (plane?.material) {
const mat = plane.material as any;
mat.setStorageBuffer("pixelBuffer", pixelBuffer);
mat.setStorageBuffer("p", paramsBuffer);
} }
//remove old observables if available this.strategy = gpuTier === 'webgpu'
scene.onBeforeRenderObservable.clear(); ? new PendulumGpuStrategy()
// --- 4. RENDER LOOP --- : new PendulumCpuStrategy();
scene.onBeforeRenderObservable.add(() => {
this.strategy.init(scene, engine);
this.startParamUpdateLoop(scene);
}
private startParamUpdateLoop(scene: any): void {
scene.onAfterRenderObservable.clear();
scene.onAfterRenderObservable.add(() => {
this.simParams.time += this.simParams.dt; this.simParams.time += this.simParams.dt;
const currentWidth = engine.getRenderWidth(); if (this.strategy) {
const currentHeight = engine.getRenderHeight(); this.strategy.updateParams({...this.simParams});
}
// Fill parameter array (must match the exact order of the WGSL struct!)
paramsData[0] = currentWidth;
paramsData[1] = currentHeight;
paramsData[2] = this.simParams.time;
paramsData[3] = this.simParams.dt;
paramsData[4] = this.simParams.g;
paramsData[5] = this.simParams.m1;
paramsData[6] = this.simParams.m2;
paramsData[7] = this.simParams.l1;
paramsData[8] = this.simParams.l2;
paramsData[9] = this.simParams.damping;
paramsData[10] = this.simParams.trailDecay;
paramsData[11] = this.simParams.impulseM1;
paramsData[12] = this.simParams.impulseM2;
paramsData[13] = 0; // Pad
this.resetImpulses(); this.resetImpulses();
paramsBuffer.update(paramsData);
// Trigger simulation and rendering
csPhysics.dispatch(1, 1, 1);
const dispatchCount = Math.ceil((currentWidth * currentHeight) / 64);
csRender.dispatch(dispatchCount, 1, 1);
}); });
} }
@@ -221,8 +167,7 @@ class PendulumComponent {
} }
pushPendulum(m1: boolean) { pushPendulum(m1: boolean) {
if (m1) if (m1) {
{
this.simParams.impulseM1 = IMPULSE_M1; this.simParams.impulseM1 = IMPULSE_M1;
return; return;
} }
@@ -235,6 +180,4 @@ class PendulumComponent {
} }
} }
export default PendulumComponent;
export default PendulumComponent

View File

@@ -0,0 +1,50 @@
/**
* CPU-side double pendulum physics mirroring PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL.
* Equations from: https://en.wikipedia.org/wiki/Double_pendulum
*/
export interface PendulumState {
theta1: number;
theta2: number;
v1: number;
v2: number;
}
export interface PendulumPhysicsParams {
dt: number;
g: number;
m1: number;
m2: number;
l1: number;
l2: number;
damping: number;
impulseM1: number;
impulseM2: number;
}
export function stepPendulumPhysics(state: PendulumState, params: PendulumPhysicsParams): void {
const t1 = state.theta1;
const t2 = state.theta2;
const v1 = state.v1;
const v2 = state.v2;
const deltaT = t1 - t2;
const num1 = -params.g * (2.0 * params.m1 + params.m2) * Math.sin(t1)
- params.m2 * params.g * Math.sin(t1 - 2.0 * t2)
- 2.0 * Math.sin(deltaT) * params.m2 * (v2 * v2 * params.l2 + v1 * v1 * params.l1 * Math.cos(deltaT));
const den1 = params.l1 * (2.0 * params.m1 + params.m2 - params.m2 * Math.cos(2.0 * deltaT));
const a1 = num1 / den1;
const num2 = 2.0 * Math.sin(deltaT) * (v1 * v1 * params.l1 * (params.m1 + params.m2) + params.g * (params.m1 + params.m2) * Math.cos(t1) + v2 * v2 * params.l2 * params.m2 * Math.cos(deltaT));
const den2 = params.l2 * (2.0 * params.m1 + params.m2 - params.m2 * Math.cos(2.0 * deltaT));
const a2 = num2 / den2;
const newV1 = (v1 + a1 * params.dt) * params.damping + params.impulseM1;
const newV2 = (v2 + a2 * params.dt) * params.damping + params.impulseM2;
state.v1 = newV1;
state.v2 = newV2;
state.theta1 = t1 + newV1 * params.dt;
state.theta2 = t2 + newV2 * params.dt;
}

View File

@@ -0,0 +1,134 @@
import {Constants, Engine, MeshBuilder, RenderTargetTexture, Scene, ShaderMaterial, Texture, Vector2, WebGPUEngine} from '@babylonjs/core';
import {PENDULUM_DISPLAY_FRAGMENT_GLSL, PENDULUM_FEEDBACK_FRAGMENT_GLSL, PENDULUM_VERTEX_SHADER_GLSL} from '../pendulum-glsl.shader';
import {PendulumState, stepPendulumPhysics} from './pendulum-cpu-physics';
import {PendulumSimParams, PendulumSimulationStrategy} from './pendulum-simulation.strategy';
export class PendulumCpuStrategy implements PendulumSimulationStrategy {
private scene: Scene | null = null;
private state: PendulumState = {theta1: Math.PI / 4, theta2: Math.PI / 2, v1: 0, v2: 0};
private params: PendulumSimParams | null = null;
private feedbackMaterial: ShaderMaterial | null = null;
private displayMaterial: ShaderMaterial | null = null;
private rttA: RenderTargetTexture | null = null;
private rttB: RenderTargetTexture | null = null;
private pingPong = false;
init(scene: Scene, engine: WebGPUEngine | Engine): void {
this.scene = scene;
const existingPlane = scene.getMeshByName("plane");
if (existingPlane) {
scene.removeMesh(existingPlane);
}
const width = engine.getRenderWidth();
const height = engine.getRenderHeight();
this.rttA = new RenderTargetTexture("rttA", {width, height}, scene, false, true, Constants.TEXTURETYPE_FLOAT);
this.rttB = new RenderTargetTexture("rttB", {width, height}, scene, false, true, Constants.TEXTURETYPE_FLOAT);
this.rttA.wrapU = Texture.CLAMP_ADDRESSMODE;
this.rttA.wrapV = Texture.CLAMP_ADDRESSMODE;
this.rttB.wrapU = Texture.CLAMP_ADDRESSMODE;
this.rttB.wrapV = Texture.CLAMP_ADDRESSMODE;
// Size 2 maps positions to -1..1 which fills clip space (vertex shader bypasses camera)
const feedbackPlane = MeshBuilder.CreatePlane("feedbackPlane", {size: 2}, scene);
feedbackPlane.alwaysSelectAsActiveMesh = true;
// Use layer mask to exclude from main scene rendering (only rendered via RTT)
feedbackPlane.layerMask = 0x10000000;
this.feedbackMaterial = new ShaderMaterial("feedbackMat", scene, {
vertexSource: PENDULUM_VERTEX_SHADER_GLSL,
fragmentSource: PENDULUM_FEEDBACK_FRAGMENT_GLSL
}, {
attributes: ["position", "uv"],
uniforms: ["resolution", "theta1", "theta2", "l1", "l2", "trailDecay"],
samplers: ["previousFrame"]
});
this.feedbackMaterial.backFaceCulling = false;
feedbackPlane.material = this.feedbackMaterial;
this.rttA.renderList!.push(feedbackPlane);
this.rttB.renderList!.push(feedbackPlane);
// Size 2 maps positions to -1..1 which fills clip space (vertex shader bypasses camera)
const displayPlane = MeshBuilder.CreatePlane("displayPlane", {size: 2}, scene);
displayPlane.alwaysSelectAsActiveMesh = true;
this.displayMaterial = new ShaderMaterial("displayMat", scene, {
vertexSource: PENDULUM_VERTEX_SHADER_GLSL,
fragmentSource: PENDULUM_DISPLAY_FRAGMENT_GLSL
}, {
attributes: ["position", "uv"],
uniforms: ["resolution", "theta1", "theta2", "l1", "l2"],
samplers: ["trailTexture"]
});
this.displayMaterial.backFaceCulling = false;
this.displayMaterial.disableDepthWrite = true;
displayPlane.material = this.displayMaterial;
// RTTs are rendered manually via writeTarget.render() -- do NOT add to customRenderTargets
this.startRenderLoop(scene, width, height);
}
updateParams(params: PendulumSimParams): void {
this.params = params;
}
dispose(): void {
if (this.scene) {
this.scene.onBeforeRenderObservable.clear();
}
this.rttA?.dispose();
this.rttB?.dispose();
this.feedbackMaterial?.dispose();
this.displayMaterial?.dispose();
this.scene = null;
}
private startRenderLoop(scene: Scene, width: number, height: number): void {
const resolution = new Vector2(width, height);
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.params || !this.feedbackMaterial || !this.displayMaterial || !this.rttA || !this.rttB) {
return;
}
stepPendulumPhysics(this.state, {
dt: this.params.dt,
g: this.params.g,
m1: this.params.m1,
m2: this.params.m2,
l1: this.params.l1,
l2: this.params.l2,
damping: this.params.damping,
impulseM1: this.params.impulseM1,
impulseM2: this.params.impulseM2
});
const readTarget = this.pingPong ? this.rttA : this.rttB;
const writeTarget = this.pingPong ? this.rttB : this.rttA;
this.feedbackMaterial.setTexture("previousFrame", readTarget);
this.feedbackMaterial.setVector2("resolution", resolution);
this.feedbackMaterial.setFloat("theta1", this.state.theta1);
this.feedbackMaterial.setFloat("theta2", this.state.theta2);
this.feedbackMaterial.setFloat("l1", this.params.l1);
this.feedbackMaterial.setFloat("l2", this.params.l2);
this.feedbackMaterial.setFloat("trailDecay", this.params.trailDecay);
writeTarget.render();
this.displayMaterial.setTexture("trailTexture", writeTarget);
this.displayMaterial.setVector2("resolution", resolution);
this.displayMaterial.setFloat("theta1", this.state.theta1);
this.displayMaterial.setFloat("theta2", this.state.theta2);
this.displayMaterial.setFloat("l1", this.params.l1);
this.displayMaterial.setFloat("l2", this.params.l2);
this.pingPong = !this.pingPong;
});
}
}

View File

@@ -0,0 +1,112 @@
import {Camera, ComputeShader, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, StorageBuffer, WebGPUEngine} from '@babylonjs/core';
import {PENDULUM_FRAGMENT_SHADER_WGSL, PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL, PENDULUM_RENDER_COMPUTE_SHADER_WGSL, PENDULUM_VERTEX_SHADER_WGSL} from '../pendulum.shader';
import {PendulumSimParams, PendulumSimulationStrategy} from './pendulum-simulation.strategy';
export class PendulumGpuStrategy implements PendulumSimulationStrategy {
private scene: Scene | null = null;
private paramsData = new Float32Array(14);
private paramsBuffer: StorageBuffer | null = null;
init(scene: Scene, engine: WebGPUEngine | Engine): void {
this.scene = scene;
const gpuEngine = engine as WebGPUEngine;
gpuEngine.resize();
const width = gpuEngine.getRenderWidth();
const height = gpuEngine.getRenderHeight();
const totalPixels = width * height;
const pixelBuffer = new StorageBuffer(gpuEngine, totalPixels * 4);
const stateBuffer = new StorageBuffer(gpuEngine, 4 * 4);
stateBuffer.update(new Float32Array([Math.PI / 4, Math.PI / 2, 0, 0]));
this.paramsBuffer = new StorageBuffer(gpuEngine, 14 * 4);
const csPhysics = new ComputeShader("physics", gpuEngine,
{computeSource: PENDULUM_PHYSIC_COMPUTE_SHADER_WGSL},
{bindingsMapping: {"state": {group: 0, binding: 0}, "p": {group: 0, binding: 1}}}
);
csPhysics.setStorageBuffer("state", stateBuffer);
csPhysics.setStorageBuffer("p", this.paramsBuffer);
const csRender = new ComputeShader("render", gpuEngine,
{computeSource: PENDULUM_RENDER_COMPUTE_SHADER_WGSL},
{bindingsMapping: {"pixelBuffer": {group: 0, binding: 0}, "p": {group: 0, binding: 1}, "state": {group: 0, binding: 2}}}
);
csRender.setStorageBuffer("pixelBuffer", pixelBuffer);
csRender.setStorageBuffer("p", this.paramsBuffer);
csRender.setStorageBuffer("state", stateBuffer);
// Create WGSL display plane and material
const displayMaterial = new ShaderMaterial("pendulumWgslMat", scene, {
vertexSource: PENDULUM_VERTEX_SHADER_WGSL,
fragmentSource: PENDULUM_FRAGMENT_SHADER_WGSL
}, {
attributes: ["position"],
uniforms: [],
storageBuffers: ["pixelBuffer", "p"],
shaderLanguage: ShaderLanguage.WGSL
});
displayMaterial.setStorageBuffer("pixelBuffer", pixelBuffer);
displayMaterial.setStorageBuffer("p", this.paramsBuffer);
displayMaterial.backFaceCulling = false;
displayMaterial.disableDepthWrite = true;
const plane = MeshBuilder.CreatePlane("pendulumPlane", {size: 100}, scene);
const camera = scene.activeCamera as Camera;
if (camera) {
plane.lookAt(camera.position);
}
plane.alwaysSelectAsActiveMesh = true;
plane.material = displayMaterial;
scene.onBeforeRenderObservable.clear();
scene.onBeforeRenderObservable.add(() => {
if (!this.paramsBuffer) {
return;
}
const currentWidth = gpuEngine.getRenderWidth();
const currentHeight = gpuEngine.getRenderHeight();
this.paramsData[0] = currentWidth;
this.paramsData[1] = currentHeight;
this.paramsBuffer.update(this.paramsData);
csPhysics.dispatch(1, 1, 1);
const dispatchCount = Math.ceil((currentWidth * currentHeight) / 64);
csRender.dispatch(dispatchCount, 1, 1);
});
}
updateParams(params: PendulumSimParams): void {
// paramsData[0] (width) and paramsData[1] (height) are set in the render loop
this.paramsData[2] = params.time;
this.paramsData[3] = params.dt;
this.paramsData[4] = params.g;
this.paramsData[5] = params.m1;
this.paramsData[6] = params.m2;
this.paramsData[7] = params.l1;
this.paramsData[8] = params.l2;
this.paramsData[9] = params.damping;
this.paramsData[10] = params.trailDecay;
this.paramsData[11] = params.impulseM1;
this.paramsData[12] = params.impulseM2;
this.paramsData[13] = 0;
}
dispose(): void {
if (this.scene) {
this.scene.onBeforeRenderObservable.clear();
const plane = this.scene.getMeshByName("pendulumPlane");
if (plane) {
this.scene.removeMesh(plane);
plane.dispose();
}
}
this.paramsBuffer = null;
this.scene = null;
}
}

View File

@@ -0,0 +1,21 @@
import {Engine, Scene, WebGPUEngine} from '@babylonjs/core';
export interface PendulumSimParams {
time: number;
dt: number;
g: number;
m1: number;
m2: number;
l1: number;
l2: number;
damping: number;
trailDecay: number;
impulseM1: number;
impulseM2: number;
}
export interface PendulumSimulationStrategy {
init(scene: Scene, engine: WebGPUEngine | Engine): void;
updateParams(params: PendulumSimParams): void;
dispose(): void;
}

View File

@@ -1,11 +1,11 @@
<mat-card class="algo-container sorting-card"> <mat-card class="w-full max-w-[1920px] p-[clamp(10px,3vw,20px)]">
<mat-card-header> <mat-card-header>
<mat-card-title>{{ 'SORTING.TITLE' | translate }}</mat-card-title> <mat-card-title>{{ 'SORTING.TITLE' | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<app-information [algorithmInformation]="algoInformation"/> <app-information [algorithmInformation]="algoInformation"/>
<div class="controls-panel"> <div class="flex gap-[clamp(5px,2vw,10px)] mb-[clamp(10px,3vw,20px)] items-center flex-wrap mt-2.5 text-[0.9em]">
<mat-form-field appearance="fill"> <mat-form-field class="w-[clamp(150px,20vw,200px)]" appearance="fill">
<mat-label>{{ 'SORTING.ALGORITHM' | translate }}</mat-label> <mat-label>{{ 'SORTING.ALGORITHM' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedAlgorithm"> <mat-select [(ngModel)]="selectedAlgorithm">
@for (algo of algoInformation.entries; track algo.name) { @for (algo of algoInformation.entries; track algo.name) {
@@ -14,7 +14,7 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline"> <mat-form-field class="w-[clamp(150px,20vw,200px)]" appearance="outline">
<mat-label>{{ 'SORTING.ARRAY_SIZE' | translate }}</mat-label> <mat-label>{{ 'SORTING.ARRAY_SIZE' | translate }}</mat-label>
<input <input
matInput matInput
@@ -27,7 +27,7 @@
/> />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="controls-panel"> <div class="flex gap-[clamp(5px,2vw,10px)] mb-[clamp(10px,3vw,20px)] items-center flex-wrap">
<button mat-raised-button color="primary" (click)="startSorting()"> <button mat-raised-button color="primary" (click)="startSorting()">
<mat-icon>play_arrow</mat-icon> {{ 'SORTING.START' | translate }} <mat-icon>play_arrow</mat-icon> {{ 'SORTING.START' | translate }}
</button> </button>
@@ -43,17 +43,19 @@
</button> </button>
</div> </div>
<div class="controls-panel"> <div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p> <p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
</div> </div>
<div class="visualization-area"> <div class="flex items-end h-[clamp(200px,40vh,400px)] border-b border-app-fg mb-[clamp(10px,3vw,20px)] gap-px bg-card-bg">
@for (item of sortArray; track $index) { @for (item of sortArray; track $index) {
<div <div
class="bar" class="grow w-2.5 min-w-px transition-all duration-[50ms]"
[style.height.px]="item.value * 3" [style.height.px]="item.value * 3"
[class.unsorted]="item.state === 'unsorted'" [ngClass]="{
[class.comparing]="item.state === 'comparing'" 'bg-[#424242]': item.state === 'unsorted',
[class.sorted]="item.state === 'sorted'" 'bg-[#ffeb3b]': item.state === 'comparing',
'bg-[#4caf50]': item.state === 'sorted'
}"
></div> ></div>
} }
</div> </div>

View File

@@ -1,9 +1,9 @@
<section class="imprint"> <section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
<mat-card class="imprint-card"> <mat-card class="p-fluid-md">
<h2 class="imprint-title">{{ 'IMPRINT.TITLE' | translate }}</h2> <h2 class="m-0 mb-4 text-[clamp(1rem,3vw,1.2rem)] font-semibold">{{ 'IMPRINT.TITLE' | translate }}</h2>
<div class="imprint-section"> <div class="grid gap-1 mb-4">
<p class="imprint-label">{{ 'IMPRINT.PARAGRAPH' | translate }}</p> <p class="text-xs tracking-[0.04em] uppercase opacity-70 m-0">{{ 'IMPRINT.PARAGRAPH' | translate }}</p>
<p> <p>
Andreas Dahm<br /> Andreas Dahm<br />
@@ -13,8 +13,8 @@
</p> </p>
</div> </div>
<div class="imprint-section"> <div class="grid gap-1 mb-4">
<p class="imprint-label">{{ 'IMPRINT.CONTACT' | translate }}</p> <p class="text-xs tracking-[0.04em] uppercase opacity-70 m-0">{{ 'IMPRINT.CONTACT' | translate }}</p>
<p> <p>
E-Mail: E-Mail:
<a href="mailto:andreas&#46;dahm&#64;gmail&#46;com"> <a href="mailto:andreas&#46;dahm&#64;gmail&#46;com">

View File

@@ -1,38 +1,38 @@
<h2 mat-dialog-title>{{ project.title | translate }}</h2> <h2 mat-dialog-title>{{ project.title | translate }}</h2>
<mat-dialog-content #dialogContent> <mat-dialog-content #dialogContent>
<div class="project-dialog-layout"> <div class="flex flex-col gap-6">
<div class="project-info"> <div>
<p class="introduction">{{ project.introduction | translate }}</p> <p class="text-lg leading-relaxed opacity-90 mb-4">{{ project.introduction | translate }}</p>
<div class="features-list"> <div class="mb-6">
<ul> <ul class="pl-5">
@for(bullet of project.bulletPoints; track bullet) { @for(bullet of project.bulletPoints; track bullet) {
<li>{{ bullet | translate }}</li> <li class="mb-2">{{ bullet | translate }}</li>
} }
</ul> </ul>
</div> </div>
<div class="insight-grid"> <div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
<div class="insight-card technical"> <div class="p-5 rounded-xl bg-black/[.03] border border-black/[.05] dark:bg-white/[.05] dark:border-white/10">
<div class="insight-header"> <div class="flex items-center gap-3 mb-3 text-link">
<mat-icon>settings_suggest</mat-icon> <mat-icon class="!text-[24px] !w-6 !h-6">settings_suggest</mat-icon>
<h3>{{ 'PROJECTS.SECTION.TECHNICAL' | translate }}</h3> <h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.TECHNICAL' | translate }}</h3>
</div> </div>
<ul> <ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
@for(challenge of project.challenges; track challenge) { @for(challenge of project.challenges; track challenge) {
<li>{{ challenge | translate }}</li> <li class="mb-1">{{ challenge | translate }}</li>
} }
</ul> </ul>
</div> </div>
<div class="insight-card softskills"> <div class="p-5 rounded-xl bg-black/[.03] border border-black/[.05] dark:bg-white/[.05] dark:border-white/10">
<div class="insight-header"> <div class="flex items-center gap-3 mb-3 text-link">
<mat-icon>psychology</mat-icon> <mat-icon class="!text-[24px] !w-6 !h-6">psychology</mat-icon>
<h3>{{ 'PROJECTS.SECTION.LEARNINGS' | translate }}</h3> <h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.LEARNINGS' | translate }}</h3>
</div> </div>
<ul> <ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
@for(learning of project.learnings; track learning) { @for(learning of project.learnings; track learning) {
<li>{{ learning | translate }}</li> <li class="mb-1">{{ learning | translate }}</li>
} }
</ul> </ul>
</div> </div>
@@ -41,14 +41,14 @@
@if (project.images.length > 0) @if (project.images.length > 0)
{ {
<div class="media-section"> <div class="my-4 rounded-xl overflow-hidden bg-black">
<swiper-container class="my-swiper" [attr.slides-per-view]="1" [attr.space-between]="12" [attr.navigation]="true" <swiper-container class="my-swiper rounded-xl" [attr.slides-per-view]="1" [attr.space-between]="12" [attr.navigation]="true"
[attr.pagination]="true" [attr.keyboard]="true" style="width: 100%;"> [attr.pagination]="true" [attr.keyboard]="true" style="width: 100%;">
@for (img of project.images; track img) { @for (img of project.images; track img) {
<swiper-slide> <swiper-slide class="rounded-xl overflow-hidden flex flex-col bg-[#222]">
<img class="slide-img" [src]="img.url" [alt]="project.title | translate" /> <img class="w-full h-auto !max-h-[clamp(300px,60vh,512px)] object-contain block shrink-0" [src]="img.url" [alt]="project.title | translate" />
@if (img.source) { @if (img.source) {
<div class="slide-source"> <div class="text-xs text-[#aaa] bg-[#2a2a2a] p-2 text-right border-t border-[#444]">
{{ img.source }} {{ img.source }}
</div> </div>
} }
@@ -58,8 +58,8 @@
</div> </div>
} }
<div class="footer-details"> <div class="flex flex-col gap-4 pt-4 border-t border-black/10 dark:border-white/10">
<div class="tech-stack"> <div class="flex flex-wrap">
<mat-chip-set aria-label="Technologies"> <mat-chip-set aria-label="Technologies">
@for(tech of project.technologies; track tech) { @for(tech of project.technologies; track tech) {
<mat-chip>{{tech}}</mat-chip> <mat-chip>{{tech}}</mat-chip>
@@ -67,7 +67,7 @@
</mat-chip-set> </mat-chip-set>
</div> </div>
<div class="link-section"> <div class="flex flex-wrap gap-fluid-sm mt-6">
@for(link of project.links; track link) @for(link of project.links; track link)
{ {
<a mat-button href="{{link.url}}" target="_blank" rel="noopener noreferrer"> <a mat-button href="{{link.url}}" target="_blank" rel="noopener noreferrer">

View File

@@ -1,117 +0,0 @@
.project-dialog-layout {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.introduction {
font-size: 1.1rem;
line-height: 1.6;
opacity: 0.9;
margin-bottom: 1rem;
}
.features-list {
margin-bottom: 1.5rem;
ul {
padding-left: 1.2rem;
li {
margin-bottom: 0.5rem;
}
}
}
.insight-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.insight-card {
padding: 1.25rem;
border-radius: 12px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.05);
.insight-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
color: var(--link-color);
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
h3 {
margin: 0;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
}
ul {
margin: 0;
padding-left: 1.2rem;
font-size: 0.95rem;
line-height: 1.5;
opacity: 0.85;
li {
margin-bottom: 0.4rem;
}
}
}
.dark .insight-card {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.media-section {
margin: 1rem 0;
border-radius: 12px;
overflow: hidden;
background: #000;
}
.footer-details {
display: flex;
flex-direction: column;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.dark .footer-details {
border-top-color: rgba(255, 255, 255, 0.1);
}
.tech-stack {
display: flex;
flex-wrap: wrap;
}
.link-section {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
a {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
}
@media (max-width: 600px) {
.insight-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,52 +1,52 @@
<div class="card-grid"> <div class="grid gap-fluid-md grid-cols-[repeat(auto-fill,minmax(min(100%,450px),1fr))] max-w-app mx-4 mt-auto">
@if (featuredProject(); as project) { @if (featuredProject(); as project) {
<mat-card class="project-card featured"> <mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full col-span-full hover:-translate-y-[5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
<mat-card-header> <mat-card-header class="pb-4">
<mat-card-title>{{ project.title | translate }}</mat-card-title> <mat-card-title>{{ project.title | translate }}</mat-card-title>
<mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle> <mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle>
</mat-card-header> </mat-card-header>
@if(project.images.length > 0) { @if(project.images.length > 0) {
<img mat-card-image [src]="project.images[0].url" [alt]="project.title | translate"> <img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="project.images[0].url" [alt]="project.title | translate">
} @else { } @else {
<div class="icon-container"> <div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]">
<mat-icon class="fallback-icon">{{ project.icon }}</mat-icon> <mat-icon class="text-[clamp(3rem,8vw,4rem)] w-[clamp(3rem,8vw,4rem)] h-[clamp(3rem,8vw,4rem)] text-[#666]">{{ project.icon }}</mat-icon>
</div> </div>
} }
<mat-card-content> <mat-card-content class="grow pt-4 pb-4">
<p>{{ project.introduction | translate }}</p> <p>{{ project.introduction | translate }}</p>
<mat-chip-set aria-label="Technologies"> <mat-chip-set class="pt-fluid-sm" aria-label="Technologies">
@for(tech of project.technologies; track tech) { @for(tech of project.technologies; track tech) {
<mat-chip>{{tech}}</mat-chip> <mat-chip>{{tech}}</mat-chip>
} }
</mat-chip-set> </mat-chip-set>
</mat-card-content> </mat-card-content>
<mat-card-actions> <mat-card-actions class="mt-auto">
<button mat-button (click)="openProjectDialog(project)">{{ 'PROJECTS.READ_MORE' | translate }}</button> <button mat-button (click)="openProjectDialog(project)">{{ 'PROJECTS.READ_MORE' | translate }}</button>
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
} }
@for (project of otherProjects(); track project) { @for (project of otherProjects(); track project) {
<mat-card class="project-card"> <mat-card class="card-gradient-bar transition-transform duration-200 ease-in-out flex flex-col h-full hover:-translate-y-[5px] hover:shadow-[0_4px_20px_rgba(0,0,0,0.15)]">
<mat-card-header> <mat-card-header class="pb-4">
<mat-card-title>{{ project.title | translate }}</mat-card-title> <mat-card-title>{{ project.title | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
@if(project.images.length > 0) { @if(project.images.length > 0) {
<img mat-card-image [src]="project.images[0].url" [alt]="project.title | translate"> <img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="project.images[0].url" [alt]="project.title | translate">
} @else { } @else {
<div class="icon-container"> <div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]">
<mat-icon class="fallback-icon">{{ project.icon }}</mat-icon> <mat-icon class="text-[clamp(3rem,8vw,4rem)] w-[clamp(3rem,8vw,4rem)] h-[clamp(3rem,8vw,4rem)] text-[#666]">{{ project.icon }}</mat-icon>
</div> </div>
} }
<mat-card-content> <mat-card-content class="grow pt-4 pb-4">
<p>{{ project.shortDescription | translate }}</p> <p>{{ project.shortDescription | translate }}</p>
<mat-chip-set aria-label="Technologies"> <mat-chip-set class="pt-fluid-sm" aria-label="Technologies">
@for(tech of project.technologies; track tech) { @for(tech of project.technologies; track tech) {
<mat-chip>{{tech}}</mat-chip> <mat-chip>{{tech}}</mat-chip>
} }
</mat-chip-set> </mat-chip-set>
</mat-card-content> </mat-card-content>
<mat-card-actions> <mat-card-actions class="mt-auto">
<button mat-button (click)="openProjectDialog(project)">{{ 'PROJECTS.READ_MORE' | translate }}</button> <button mat-button (click)="openProjectDialog(project)">{{ 'PROJECTS.READ_MORE' | translate }}</button>
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>

View File

@@ -0,0 +1,55 @@
import {Injectable, signal} from '@angular/core';
export type GpuTier = 'webgpu' | 'webgl' | 'none';
@Injectable({providedIn: 'root'})
export class GpuCapabilityService {
private cachedTier: GpuTier | null = null;
readonly tier = signal<GpuTier | null>(null);
async detect(): Promise<GpuTier> {
if (this.cachedTier) {
return this.cachedTier;
}
const result = await this.probe();
this.cachedTier = result;
this.tier.set(result);
return result;
}
private async probe(): Promise<GpuTier> {
if (await this.isWebGpuAvailable()) {
return 'webgpu';
}
if (this.isWebGlAvailable()) {
return 'webgl';
}
return 'none';
}
private async isWebGpuAvailable(): Promise<boolean> {
if (!navigator.gpu) {
return false;
}
try {
const adapter = await navigator.gpu.requestAdapter();
return adapter !== null;
} catch {
return false;
}
}
private isWebGlAvailable(): boolean {
try {
const canvas = document.createElement('canvas');
const context = canvas.getContext('webgl2');
return context !== null;
} catch {
return false;
}
}
}

View File

@@ -1,3 +1,5 @@
<div class="canvas-container"> <div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
<canvas #gridCanvas></canvas> <canvas #gridCanvas
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-none">
</canvas>
</div> </div>

View File

@@ -7,9 +7,3 @@
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
} }
canvas {
display: block;
width: 100%;
height: 100%;
border-width: 0;
}

View File

@@ -1,3 +1,5 @@
<div class="canvas-container"> <div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
<canvas #renderCanvas></canvas> <canvas #renderCanvas
class="block w-full h-auto aspect-square min-w-[200px] max-w-[1000px] touch-none rounded-[clamp(10px,2vw,20px)] outline-none">
</canvas>
</div> </div>

View File

@@ -1,7 +1,8 @@
import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, NgZone, OnDestroy, Output, ViewChild} from '@angular/core'; import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, NgZone, OnDestroy, Output, ViewChild} from '@angular/core';
import {MatSnackBar} from '@angular/material/snack-bar'; import {MatSnackBar} from '@angular/material/snack-bar';
import {TranslateService} from '@ngx-translate/core'; import {TranslateService} from '@ngx-translate/core';
import {ArcRotateCamera, Camera, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, Vector2, Vector3, WebGPUEngine} from '@babylonjs/core'; import {ArcRotateCamera, Camera, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, Vector2, Vector3, WebGPUEngine} from '@babylonjs/core';
import {GpuCapabilityService, GpuTier} from '../../../service/gpu-capability.service';
export interface RenderConfig { export interface RenderConfig {
mode: '2D' | '3D'; mode: '2D' | '3D';
@@ -17,7 +18,8 @@ export type RenderCallback = (material: ShaderMaterial, camera: Camera, canvas:
export interface SceneEventData { export interface SceneEventData {
scene: Scene; scene: Scene;
engine: WebGPUEngine; engine: WebGPUEngine | Engine;
gpuTier: GpuTier;
} }
@Component({ @Component({
@@ -30,6 +32,7 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
readonly ngZone = inject(NgZone); readonly ngZone = inject(NgZone);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly gpuCapability = inject(GpuCapabilityService);
@ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
@@ -38,11 +41,13 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
@Output() sceneReady = new EventEmitter<SceneEventData>(); @Output() sceneReady = new EventEmitter<SceneEventData>();
@Output() sceneResized = new EventEmitter<SceneEventData>(); @Output() sceneResized = new EventEmitter<SceneEventData>();
@Output() engineUnavailable = new EventEmitter<{ reason: string }>();
private engine!: WebGPUEngine; private engine!: WebGPUEngine | Engine;
private scene!: Scene; private scene!: Scene;
private shaderMaterial!: ShaderMaterial; private shaderMaterial!: ShaderMaterial;
private camera!: Camera; private camera!: Camera;
private gpuTier: GpuTier = 'none';
//Listener //Listener
private readonly resizeHandler = () => this.handleResize(); private readonly resizeHandler = () => this.handleResize();
@@ -63,26 +68,60 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
private async initBabylon(): Promise<void> { private async initBabylon(): Promise<void> {
const canvas = this.canvasRef.nativeElement; const canvas = this.canvasRef.nativeElement;
const tmpEngine = new WebGPUEngine(canvas); const tier = await this.gpuCapability.detect();
await tmpEngine.initAsync().then(() => { this.gpuTier = tier;
this.engine = tmpEngine;
this.scene = new Scene(this.engine);
this.setupCamera(canvas);
this.addListener(canvas);
this.createShaderMaterial();
this.createFullScreenRect();
this.sceneReady.emit({
scene: this.scene,
engine: this.engine
});
this.addRenderLoop(canvas);
}) if (tier === 'webgpu') {
.catch(() => { await this.initWebGpuEngine(canvas);
const message = this.translate.instant('WEBGPU.NOT_SUPPORTED'); return;
this.snackBar.open(message, 'OK', { duration: 8000, horizontalPosition: "center", verticalPosition: "top" }); }
this.engine = null!;
}); if (tier === 'webgl') {
this.showSnackBar('GPU.WEBGL_FALLBACK');
this.initWebGlEngine(canvas);
return;
}
this.showSnackBar('GPU.NOT_SUPPORTED');
this.engineUnavailable.emit({reason: 'no_gpu'});
}
private async initWebGpuEngine(canvas: HTMLCanvasElement): Promise<void> {
const tmpEngine = new WebGPUEngine(canvas);
try {
await tmpEngine.initAsync();
this.engine = tmpEngine;
this.setupScene(canvas);
} catch {
this.showSnackBar('GPU.WEBGL_FALLBACK');
this.gpuTier = 'webgl';
this.initWebGlEngine(canvas);
}
}
private initWebGlEngine(canvas: HTMLCanvasElement): void {
this.engine = new Engine(canvas, true);
this.setupScene(canvas);
}
private setupScene(canvas: HTMLCanvasElement): void {
this.scene = new Scene(this.engine);
this.setupCamera(canvas);
this.addListener(canvas);
this.createShaderMaterial();
this.createFullScreenRect();
this.sceneReady.emit({
scene: this.scene,
engine: this.engine,
gpuTier: this.gpuTier
});
this.addRenderLoop(canvas);
}
private showSnackBar(translationKey: string): void {
const message = this.translate.instant(translationKey);
this.snackBar.open(message, 'OK', {duration: 8000, horizontalPosition: 'center', verticalPosition: 'top'});
} }
private addListener(canvas: HTMLCanvasElement) { private addListener(canvas: HTMLCanvasElement) {
@@ -198,7 +237,8 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
this.sceneResized?.emit({ this.sceneResized?.emit({
scene: this.scene, scene: this.scene,
engine: this.engine engine: this.engine,
gpuTier: this.gpuTier
}); });
} }
} }

View File

@@ -513,8 +513,9 @@
"DISCLAIMER_4": "Der XPBD-Kompromiss: XPBD umgeht dieses komplexe Matrix-Problem völlig, indem es als lokaler Löser arbeitet. Es kombiniert die unbedingte Stabilität eines impliziten Lösers mit der enormen Geschwindigkeit, Parallelisierbarkeit und dynamischen Anpassungsfähigkeit eines expliziten Systems." "DISCLAIMER_4": "Der XPBD-Kompromiss: XPBD umgeht dieses komplexe Matrix-Problem völlig, indem es als lokaler Löser arbeitet. Es kombiniert die unbedingte Stabilität eines impliziten Lösers mit der enormen Geschwindigkeit, Parallelisierbarkeit und dynamischen Anpassungsfähigkeit eines expliziten Systems."
} }
}, },
"WEBGPU": { "GPU": {
"NOT_SUPPORTED": "WebGPU konnte nicht gestartet werden. Bitte prüfe, ob dein Browser WebGPU unterstützt." "WEBGL_FALLBACK": "WebGPU ist nicht verfügbar. WebGL wird als Fallback verwendet. Die Leistung kann eingeschränkt sein.",
"NOT_SUPPORTED": "Weder WebGPU noch WebGL konnten initialisiert werden. GPU-Visualisierungen sind nicht verfügbar."
}, },
"ALGORITHM": { "ALGORITHM": {
"TITLE": "Algorithmen", "TITLE": "Algorithmen",

View File

@@ -512,8 +512,9 @@
"DISCLAIMER_4": "The XPBD Compromise: XPBD completely bypasses this complex matrix problem by acting as a local solver. It combines the absolute stability of an implicit solver with the enormous speed, parallelizability, and dynamic adaptability of an explicit system." "DISCLAIMER_4": "The XPBD Compromise: XPBD completely bypasses this complex matrix problem by acting as a local solver. It combines the absolute stability of an implicit solver with the enormous speed, parallelizability, and dynamic adaptability of an explicit system."
} }
}, },
"WEBGPU": { "GPU": {
"NOT_SUPPORTED": "WebGPU could not be started. Please check if your browser supports WebGPU." "WEBGL_FALLBACK": "WebGPU is not available. Using WebGL as fallback. Performance may be reduced.",
"NOT_SUPPORTED": "Neither WebGPU nor WebGL could be initialized. GPU visualizations are unavailable."
}, },
"ALGORITHM": { "ALGORITHM": {
"TITLE": "Algorithms", "TITLE": "Algorithms",

View File

@@ -11,6 +11,6 @@
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<app-root></app-root> <app-root class="flex flex-col min-h-screen"></app-root>
</body> </body>
</html> </html>

View File

@@ -13,13 +13,11 @@ $dark-theme: mat.define-theme((color: (theme-type: dark, primary: mat.$cyan-pale
@include mat.core-theme($light-theme); @include mat.core-theme($light-theme);
@include mat.all-component-themes($light-theme); @include mat.all-component-themes($light-theme);
// Dark-Mode
.dark { .dark {
@include mat.all-component-colors($dark-theme); @include mat.all-component-colors($dark-theme);
} }
/* ---- Custom variables ---- */ /* ---- Custom variables (bridge Material tokens to CSS vars for Tailwind) ---- */
:root { :root {
--app-maxWidth: 1200px; --app-maxWidth: 1200px;
--app-bg: #{mat.get-theme-color($light-theme, surface-container-low)}; --app-bg: #{mat.get-theme-color($light-theme, surface-container-low)};
@@ -38,7 +36,7 @@ $dark-theme: mat.define-theme((color: (theme-type: dark, primary: mat.$cyan-pale
} }
.dark { .dark {
--app-bg: #{mat.get-theme-color($dark-theme,surface-variant)}; --app-bg: #{mat.get-theme-color($dark-theme, surface-variant)};
--app-fg: #{mat.get-theme-color($dark-theme, on-surface)}; --app-fg: #{mat.get-theme-color($dark-theme, on-surface)};
--app-card-background: #313131; --app-card-background: #313131;
--app-logo-bg: #313131; --app-logo-bg: #313131;
@@ -50,37 +48,7 @@ $dark-theme: mat.define-theme((color: (theme-type: dark, primary: mat.$cyan-pale
--link-color-hover: #9ad2ff; --link-color-hover: #9ad2ff;
} }
.dark body { /* ---- Material component theme transitions ---- */
background: radial-gradient(ellipse at 50% 0%, #1e2530 0%, #1a1a1a 65%);
}
/* ---- global background and tests ---- */
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: Inter, Roboto, Arial, sans-serif;
background-color: var(--app-bg);
color: var(--app-fg);
transition: background-color 220ms ease, color 220ms ease;
}
.material-symbols-outlined {
font-variation-settings:
"FILL" 0,
/* 0 oder 1 */
"wght" 400,
/* 100..700 */
"GRAD" 0,
/* -50..200 */
"opsz" 24;
/* 20..48 */
}
/* smooth transition between theme change */
.mat-toolbar, .mat-toolbar,
.mat-mdc-card, .mat-mdc-card,
.mat-sidenav, .mat-sidenav,
@@ -97,37 +65,14 @@ body {
fill 220ms ease; fill 220ms ease;
} }
/* links */ /* ---- Material card overrides ---- */
a {
color: var(--link-color);
text-decoration: none;
font-weight: 500;
&:hover {
color: var(--link-color-hover);
text-decoration: underline;
}
}
/* cards */
.mat-mdc-card { .mat-mdc-card {
position: relative; position: relative;
border-radius: var(--card-radius) !important; border-radius: var(--card-radius) !important;
background: var(--card-bg) !important; background: var(--card-bg) !important;
box-shadow: var(--card-shadow-outer);
overflow: hidden; overflow: hidden;
border: none; border: none;
transition: box-shadow 200ms ease, transform 200ms ease;
transition:
box-shadow 200ms ease,
transform 200ms ease;
&.algo-container {
width: 100%;
max-width: 1920px;
padding: 20px;
}
} }
.mat-mdc-card::before { .mat-mdc-card::before {
@@ -136,22 +81,12 @@ a {
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
pointer-events: none; pointer-events: none;
box-shadow: box-shadow:
inset 0 1px 0 var(--card-border-inset), inset 0 1px 0 var(--card-border-inset),
inset 0 -1px 0 var(--card-border-inset-dark); inset 0 -1px 0 var(--card-border-inset-dark);
} }
.mat-mdc-card::after { /* ---- Material accordion/expansion panel overrides ---- */
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
pointer-events: none;
}
/* accordion */
/* expansion panels like cards */
.mat-accordion { .mat-accordion {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -161,31 +96,20 @@ a {
border-radius: var(--card-radius) !important; border-radius: var(--card-radius) !important;
background: var(--card-bg) !important; background: var(--card-bg) !important;
overflow: hidden; overflow: hidden;
/* ok */
border: none !important; border: none !important;
} }
.mat-accordion .mat-expansion-panel::before { .mat-accordion .mat-expansion-panel::before {
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
pointer-events: none; pointer-events: none;
box-shadow: box-shadow:
inset 0 1px 0 var(--card-border-inset), inset 0 1px 0 var(--card-border-inset),
inset 0 -1px 0 var(--card-border-inset-dark); inset 0 -1px 0 var(--card-border-inset-dark);
} }
.mat-accordion .mat-expansion-panel::after {
content: '';
position: absolute;
inset: -1px;
border-radius: inherit;
pointer-events: none;
}
.mat-expansion-panel-header { .mat-expansion-panel-header {
background: transparent !important; background: transparent !important;
} }
@@ -194,6 +118,7 @@ a {
padding-top: 0; padding-top: 0;
} }
/* ---- Material dialog overrides ---- */
.image-dialog-panel .mat-mdc-dialog-surface { .image-dialog-panel .mat-mdc-dialog-surface {
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
@@ -210,251 +135,7 @@ a {
padding: 0; padding: 0;
} }
.link-row { /* ---- Material menu overrides ---- */
grid-row: 2;
grid-column: 2;
margin-top: .1rem;
opacity: .85;
vertical-align: center;
}
.link-with-icon {
display: inline-flex;
align-items: center;
gap: .35rem;
line-height: 1;
}
.link-with-icon mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
// algos
.algo-container {
max-width: var(--app-maxWidth);
gap: clamp(1rem, 3vw, 1.5rem);
margin-right: 1rem;
margin-left: 0.5rem;
margin-top: auto;
}
.algo-info {
margin: 0 0 1rem 0;
padding: 0.75rem 1rem;
border: 1px solid #ddd;
border-radius: 8px;
h3 {
margin: 0 0 0.5rem 0;
}
p {
margin: 0.5rem 0;
}
a {
margin-left: 0.25rem;
}
}
.controls-panel {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
align-items: center;
flex-wrap: wrap;
margin-top: 10px;
font-size: 0.9em;
mat-button-toggle-group {
border-radius: 4px;
overflow: hidden;
}
mat-form-field {
width: 200px;
}
}
.input-container {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
.input-field {
width: 150px;
}
}
canvas {
border: none;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
display: block;
margin: 0 auto;
max-width: 100%;
}
.dark canvas {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
font-size: 0.9em;
.legend-color {
display: inline-block;
width: 15px;
height: 15px;
border: 1px solid lightgray;
vertical-align: middle;
margin-right: 5px;
&.start {
background-color: green;
}
&.end {
background-color: red;
}
&.wall {
background-color: black;
}
&.visited {
background-color: skyblue;
}
&.path {
background-color: gold;
}
&.empty {
background-color: lightgray;
}
&.alive {
background-color: black;
}
&.L1 {
background-color: yellow;
}
&.L2 {
background-color: magenta;
}
&.M1 {
background-color: red;
}
&.M2 {
background-color: green;
}
&.color1 {
background-color: #FF5252;
}
&.color2 {
background-color: #448AFF;
}
&.color3 {
background-color: #4CAF50;
}
&.color4 {
background-color: #FFEB3B;
}
}
}
.controls-container {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
/* Sorting Visualization & Canvas */
.sorting-visualization-area,
.visualization-area {
display: flex;
align-items: flex-end;
height: clamp(200px, 40vh, 400px);
border-bottom: 1px solid var(--app-fg);
margin-bottom: clamp(10px, 3vw, 20px);
gap: 1px;
background-color: var(--card-bg);
.sorting-bar,
.bar {
flex-grow: 1;
background-color: #424242;
transition: height 0.05s ease-in-out, background-color 0.05s ease-in-out;
width: 10px;
min-width: 1px;
&.unsorted {
background-color: #424242;
}
&.comparing {
background-color: #ffeb3b;
}
&.sorted {
background-color: #4caf50;
}
}
}
/* ---- Modern Layouts & Typography (Grid, Flex, Clamp) ---- */
.layout-container {
width: 100%;
max-width: var(--app-maxWidth);
margin: 0 auto;
padding: clamp(1rem, 4vw, 2rem);
}
app-root {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-container {
width: 100%;
max-width: var(--app-maxWidth);
margin: 1rem auto;
}
.app-surface {
flex-grow: 1;
color: var(--app-fg);
transition: background-color 220ms ease, color 220ms ease;
}
.foot {
border-top: 1px solid rgba(0, 0, 0, .08);
padding: clamp(1rem, 2vw, 1.5rem);
text-align: center;
opacity: .8;
background: var(--app-bg);
}
/* ---- Menu Overrides ---- */
.mat-mdc-menu-item .mdc-list-item__primary-text { .mat-mdc-menu-item .mdc-list-item__primary-text {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -493,358 +174,7 @@ app-root {
border-color: rgba(255, 255, 255, .06); border-color: rgba(255, 255, 255, .06);
} }
/* ---- About Page Sections ---- */ /* ---- Swiper shadow DOM (::part cannot be Tailwind) ---- */
.about,
.imprint {
display: grid;
gap: clamp(1rem, 3vw, 1.5rem);
max-width: var(--app-maxWidth);
margin-right: 1rem;
margin-left: 1rem;
margin-top: auto;
}
.hero {
border-radius: var(--card-radius);
background: var(--card-bg);
overflow: hidden;
}
.hero-flex-container {
display: flex;
flex-wrap: wrap;
gap: clamp(1rem, 4vw, 2rem);
padding: clamp(1rem, 3vw, 1.5rem);
align-items: flex-start;
.photo {
flex: 1 1 min(100%, 425px);
max-width: 100%;
display: flex;
justify-content: center;
img {
display: block;
width: 100%;
height: auto;
max-width: 425px;
border-radius: 12px;
box-shadow: 0 6px 24px rgba(0, 0, 0, .25);
object-fit: cover;
}
}
.intro {
flex: 999 1 min(100%, 400px);
}
}
.hero .intro h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: clamp(1.5rem, 5vw, 2.5rem);
background: linear-gradient(135deg, var(--mat-sys-primary), var(--mat-sys-tertiary));
background-clip: text;
}
.hero .intro .lead {
opacity: .9;
margin: 0.5rem 0 1rem;
font-size: clamp(1rem, 2.5vw, 1.15rem);
}
.hero .intro .meta {
display: flex;
flex-direction: column;
gap: .25rem;
margin-bottom: 0.5rem;
}
.hero .intro .meta .row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .4rem;
}
.hero .intro .actions {
display: flex;
gap: .5rem;
flex-wrap: wrap;
margin-top: .5rem;
}
.skills,
.experience,
.projects,
.education {
padding: clamp(5px, 2vw, 15px);
}
.skills h2,
.experience h2,
.projects h2,
.education h2 {
margin-top: .25rem;
margin-left: .25rem;
font-size: clamp(1.2rem, 4vw, 1.8rem);
}
.skills .chip-groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
gap: clamp(0.5rem, 2vw, 1rem);
margin-left: .25rem;
margin-bottom: .5rem;
}
.skills .chip-groups h3 {
margin: .2rem 0 .4rem;
font-size: .95rem;
opacity: .85;
}
.xp-list {
margin-left: .25rem;
display: grid;
gap: clamp(0.75rem, 2vw, 1rem);
}
.xp-item .xp-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: .5rem;
}
.xp-item .xp-head .time {
opacity: .75;
font-size: clamp(0.85rem, 2vw, 0.95rem);
}
.xp-item .xp-sub {
opacity: .9;
margin-bottom: .25rem;
}
.xp-item ul {
margin: .25rem 0 .5rem 1.15rem;
}
.xp-head-grid {
display: grid;
grid-template-columns: calc(clamp(32px, 8vw, 48px) + .75rem) 1fr;
grid-template-rows: auto auto;
column-gap: clamp(0.5rem, 2vw, .75rem);
}
.logo-wrap {
grid-row: 1 / span 2;
grid-column: 1;
display: flex;
align-items: center;
}
.company-logo {
width: clamp(32px, 8vw, 48px);
height: clamp(32px, 8vw, 48px);
object-fit: contain;
opacity: .9;
border-radius: 10%;
background-color: var(--app-logo-bg);
}
.head-row {
grid-row: 1;
grid-column: 2;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: clamp(0.25rem, 1vw, 0.5rem) 1rem;
}
.head-row strong {
font-size: clamp(0.95rem, 2.5vw, 1.1rem);
}
.head-row .time {
opacity: .75;
font-size: clamp(0.85rem, 2vw, 0.95rem);
}
.company-row {
grid-row: 2;
grid-column: 2;
margin-top: .1rem;
opacity: .85;
font-size: clamp(0.85rem, 2vw, 1rem);
}
.highlights {
margin-top: .4rem;
margin-left: clamp(0.25rem, 1vw, .75rem);
padding-left: clamp(0.8rem, 2vw, 1.2rem);
}
.highlights li,
.highlights-noMargin li {
margin: .2rem 0;
font-size: clamp(0.9rem, 2vw, 1rem);
}
/* ---- Imprint ---- */
.imprint-card {
padding: clamp(1rem, 3vw, 1.5rem);
}
.imprint-title {
margin: 0 0 1rem;
font-size: clamp(1rem, 3vw, 1.2rem);
font-weight: 600;
}
.imprint-section {
display: grid;
gap: 0.25rem;
margin-bottom: 1rem;
}
.imprint-label {
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.7;
margin: 0;
}
/* ---- Projects Page & Dialog ---- */
.card-grid {
display: grid;
gap: clamp(1rem, 3vw, 1.5rem);
grid-template-columns: repeat(auto-fill, minmax(min(100%, 450px), 1fr));
max-width: var(--app-maxWidth);
margin-right: 1rem;
margin-left: 1rem;
margin-top: auto;
}
.algo-card {
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
display: flex;
flex-direction: column;
cursor: pointer;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
}
.algo-card::after, .project-card::after {
inset: unset;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--mat-sys-primary), var(--mat-sys-tertiary));
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
.algo-icon-wrap {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in oklab, var(--mat-sys-primary) 15%, transparent);
color: var(--mat-sys-primary);
margin-bottom: 1rem;
mat-icon {
font-size: 26px;
width: 26px;
height: 26px;
}
}
.algo-page-title {
margin: 0 0 0.5rem 0;
font-size: clamp(1.4rem, 4vw, 2rem);
}
.algo-card-title {
font-size: 1.05rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
}
.algo-card-desc {
margin: 0;
opacity: 0.75;
font-size: 0.9rem;
line-height: 1.55;
}
.project-card {
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
display: flex;
flex-direction: column;
height: 100%;
}
.project-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.project-card.featured {
grid-column: 1 / -1;
}
.project-card mat-card-header {
padding-bottom: 1rem;
}
.project-card mat-card-content {
flex-grow: 1;
padding-top: 1rem;
padding-bottom: 1rem;
}
.project-card mat-chip-set {
padding-top: clamp(0.5rem, 2vw, 1rem);
}
.project-card mat-card-actions {
margin-top: auto;
}
.icon-container {
display: flex;
justify-content: center;
align-items: center;
height: clamp(150px, 20vw, 200px);
background-color: #f0f0f0;
}
.fallback-icon {
font-size: clamp(3rem, 8vw, 4rem);
width: clamp(3rem, 8vw, 4rem);
height: clamp(3rem, 8vw, 4rem);
color: #666;
}
img[mat-card-image] {
width: 100%;
height: clamp(150px, 25vw, 250px);
object-fit: cover;
}
.my-swiper {
border-radius: 12px;
}
.my-swiper::part(button-prev), .my-swiper::part(button-prev),
.my-swiper::part(button-next) { .my-swiper::part(button-next) {
width: 35px; width: 35px;
@@ -866,101 +196,3 @@ img[mat-card-image] {
.my-swiper::part(pagination) { .my-swiper::part(pagination) {
bottom: 12px; bottom: 12px;
} }
swiper-slide {
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
background-color: #222;
}
.slide-img {
width: 100%;
height: auto;
max-height: clamp(300px, 60vh, 512px) !important;
object-fit: contain;
display: block;
flex-shrink: 0;
}
.slide-source {
font-size: 0.75rem;
color: #aaa;
background: #2a2a2a;
padding: 0.5rem;
text-align: right;
border-top: 1px solid #444;
}
.link-section {
display: flex;
gap: clamp(0.5rem, 2vw, 1rem);
margin-top: 1.5rem;
flex-wrap: wrap;
}
/* ---- Shared Elements ---- */
.canvas-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 1000px;
margin: 0 auto;
}
.canvas-container canvas {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1 / 1;
min-width: 200px;
max-width: 1000px;
touch-action: none;
border: none;
border-radius: clamp(10px, 2vw, 20px);
outline: none;
}
.category-cards {
display: flex;
flex-wrap: wrap;
gap: clamp(0.5rem, 2vw, 1rem);
margin-top: clamp(1rem, 3vw, 2rem);
}
.category-cards mat-card {
cursor: pointer;
flex: 1 1 300px;
min-width: 300px;
max-width: 450px;
}
.category-cards mat-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.sorting-card {
width: 100%;
max-width: 1920px;
padding: clamp(10px, 3vw, 20px);
}
.sorting-card .controls-panel {
display: flex;
gap: clamp(5px, 2vw, 10px);
margin-bottom: clamp(10px, 3vw, 20px);
align-items: center;
flex-wrap: wrap;
}
.sorting-card .controls-panel mat-form-field {
width: clamp(150px, 20vw, 200px);
}
.sorting-card .info-panel {
margin-top: 10px;
font-size: 0.9em;
}

67
src/tailwind.css Normal file
View File

@@ -0,0 +1,67 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
*,
::before,
::after {
border-width: 0;
border-style: solid;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: Inter, Roboto, Arial, sans-serif;
background-color: var(--app-bg);
color: var(--app-fg);
transition: background-color 220ms ease, color 220ms ease;
}
a {
@apply text-link no-underline font-medium hover:text-link-hover hover:underline;
}
.material-symbols-outlined {
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
}
.dark body {
background: radial-gradient(ellipse at 50% 0%, #1e2530 0%, #1a1a1a 65%);
}
canvas {
@apply border-none block mx-auto max-w-full;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
}
.dark canvas {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
}
@layer components {
.legend-swatch {
@apply inline-block w-[15px] h-[15px] border border-gray-300 align-middle mr-[5px];
}
.card-gradient-bar {
@apply relative;
}
.card-gradient-bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--mat-sys-primary), var(--mat-sys-tertiary));
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
}

37
tailwind.config.js Normal file
View File

@@ -0,0 +1,37 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,ts}'],
darkMode: 'class',
corePlugins: { preflight: false },
theme: {
extend: {
colors: {
'app-bg': 'var(--app-bg)',
'app-fg': 'var(--app-fg)',
'card-bg': 'var(--card-bg)',
'link': 'var(--link-color)',
'link-hover': 'var(--link-color-hover)',
'logo-bg': 'var(--app-logo-bg)',
},
maxWidth: {
'app': 'var(--app-maxWidth)',
},
borderRadius: {
'card': 'var(--card-radius)',
},
fontFamily: {
'sans': ['Inter', 'Roboto', 'Arial', 'sans-serif'],
},
spacing: {
'fluid-sm': 'clamp(0.5rem, 2vw, 1rem)',
'fluid-md': 'clamp(1rem, 3vw, 1.5rem)',
'fluid-lg': 'clamp(1rem, 4vw, 2rem)',
},
screens: {
'mobile': { 'max': '760px' },
'sm-dialog': { 'max': '600px' },
'tablet': { 'max': '900px' },
},
},
},
};