Compare commits

..

13 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
9a6b5198ad .gitea/workflows/build-Frontend-a.yml aktualisiert
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m11s
Build, Test & Push Frontend / docker (push) Successful in 1m0s
2026-03-28 13:05:00 +01:00
Andreas Dahm
2d82cdfd6b Merge remote-tracking branch 'origin/main'
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m21s
Build, Test & Push Frontend / docker (push) Successful in 1m16s
2026-03-26 09:09:25 +01:00
Andreas Dahm
00ebedfc90 Updated about page 2026-03-26 09:09:15 +01:00
4bf5b3e2c4 Merge pull request 'feature/4ColorProblem' (#32) from feature/4ColorProblem into main
All checks were successful
Build, Test & Push Frontend / quality-check (push) Successful in 1m32s
Build, Test & Push Frontend / docker (push) Successful in 1m7s
Reviewed-on: #32
2026-03-08 11:11:19 +01:00
ab730f8f3b removed unused method
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-03-08 11:09:57 +01:00
0104bad59f optimized limits a little bit 2026-03-08 11:08:11 +01:00
54b33daa40 Add Four-Color algorithm page
Introduce an interactive Four Color Theorem demo: add FourColorComponent (template, styles, standalone component) with canvas-based grid, region generation, adjacency detection and a backtracking solver; include models for nodes/regions and grid constants. Wire up route and RouterConstants/UrlConstants, add menu entry in AlgorithmsService, and add i18n entries for English and German. Also add global color styles in styles.scss and a few minor i18n text adjustments.
2026-03-08 11:01:14 +01:00
60 changed files with 4852 additions and 3322 deletions

View File

@@ -92,12 +92,18 @@ jobs:
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
# Convert repository owner to lowercase
OWNER="${GITHUB_REPOSITORY_OWNER}"
OWNER="$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')"
echo "owner=$OWNER" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3 - uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} registry: git.andreas-dahm.eu
password: ${{ secrets.DOCKERHUB_TOKEN }} username: ${{ github.repository_owner }}
password: ${{ secrets.TOKEN_GITEA }}
- name: Build & Push (branch + sha tags) - name: Build & Push (branch + sha tags)
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -105,8 +111,8 @@ jobs:
context: . context: .
push: true push: true
tags: | tags: |
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/playground:frontend-a-${{ steps.prep.outputs.branch }} git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/playground:frontend-a-${{ steps.prep.outputs.branch }}
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/playground:frontend-a-${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }} git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/playground:frontend-a-${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }}
- name: Also push moving main tag - name: Also push moving main tag
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -114,4 +120,4 @@ jobs:
context: . context: .
push: true push: true
tags: | tags: |
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/playground:frontend-a-main git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/playground:frontend-a-main

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

@@ -15,5 +15,6 @@ export const routes: Routes = [
{ path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent) }, { path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent) },
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default) }, { path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default) },
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent) }, { path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent) },
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent) },
]; ];

View File

@@ -55,6 +55,11 @@
LINK: '/algorithms/cloth', LINK: '/algorithms/cloth',
}; };
static readonly FOUR_COLOR = {
PATH: 'algorithms/four_color',
LINK: '/algorithms/four_color',
};
static readonly IMPRINT = { static readonly IMPRINT = {
PATH: 'imprint', PATH: 'imprint',
LINK: '/imprint', LINK: '/imprint',

View File

@@ -23,5 +23,6 @@
static readonly XPBD_WIKI = 'https://www.emergentmind.com/topics/extended-position-based-dynamics-xpbd' static readonly XPBD_WIKI = 'https://www.emergentmind.com/topics/extended-position-based-dynamics-xpbd'
static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units' static readonly GPU_COMPUTING_WIKI = 'https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units'
static readonly DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur' static readonly DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur'
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
} }

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"

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

@@ -33,7 +33,7 @@ export class AboutComponent {
{ {
key: 'ABOUT.XP.COMPANY9', key: 'ABOUT.XP.COMPANY9',
logo: AssetsConstants.COLORDIGITAL_LOGO, logo: AssetsConstants.COLORDIGITAL_LOGO,
no_highlights: true no_highlights: false
}, },
{ {
key: 'ABOUT.XP.COMPANY8', key: 'ABOUT.XP.COMPANY8',
@@ -134,6 +134,8 @@ export class AboutComponent {
'ABOUT.SKILLS.JAVA', 'ABOUT.SKILLS.JAVA',
'ABOUT.SKILLS.SPRING', 'ABOUT.SKILLS.SPRING',
'ABOUT.SKILLS.ANGULAR', 'ABOUT.SKILLS.ANGULAR',
'ABOUT.SKILLS.VUE',
'ABOUT.SKILLS.REACT',
'ABOUT.SKILLS.TYPESCRIPT', 'ABOUT.SKILLS.TYPESCRIPT',
'ABOUT.SKILLS.CSHARP', 'ABOUT.SKILLS.CSHARP',
'ABOUT.SKILLS.PYTHON' 'ABOUT.SKILLS.PYTHON'
@@ -145,7 +147,9 @@ export class AboutComponent {
'ABOUT.TOOLS.DOCKER', 'ABOUT.TOOLS.DOCKER',
'ABOUT.TOOLS.K8S', 'ABOUT.TOOLS.K8S',
'ABOUT.TOOLS.JENKINS', 'ABOUT.TOOLS.JENKINS',
'ABOUT.TOOLS.POSTGRES' 'ABOUT.TOOLS.POSTGRES',
'ABOUT.TOOLS.MONGO',
'ABOUT.TOOLS.GITLAB'
]; ];
skillsEngineering = [ skillsEngineering = [

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

@@ -63,6 +63,13 @@ export class AlgorithmsService {
description: 'ALGORITHM.CLOTH.DESCRIPTION', description: 'ALGORITHM.CLOTH.DESCRIPTION',
routerLink: RouterConstants.CLOTH.LINK, routerLink: RouterConstants.CLOTH.LINK,
icon: 'texture' icon: 'texture'
},
{
id: 'fourColor',
title: 'ALGORITHM.FOUR_COLOR.TITLE',
description: 'ALGORITHM.FOUR_COLOR.DESCRIPTION',
routerLink: RouterConstants.FOUR_COLOR.LINK,
icon: 'palette'
} }
]; ];

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

@@ -0,0 +1,72 @@
<mat-card class="w-full max-w-[1920px] p-5">
<mat-card-header>
<mat-card-title>{{ 'FOUR_COLOR.TITLE' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<app-information [algorithmInformation]="algoInformation"/>
<div class="flex flex-col mb-4">
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<button mat-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-stroked-button (click)="resetColors()">{{ 'FOUR_COLOR.CLEAR' | translate }}</button>
</div>
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
<div class="flex gap-3 items-center flex-wrap">
<mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_HEIGHT' | translate }}</mat-label>
<input
matInput
type="number"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
[(ngModel)]="gridRows"
(ngModelChange)="applyGridSize()"
/>
</mat-form-field>
<mat-form-field appearance="outline" class="w-[150px]">
<mat-label>{{ 'ALGORITHM.GRID_WIDTH' | translate }}</mat-label>
<input
matInput
type="number"
[min]="MIN_GRID_SIZE"
[max]="MAX_GRID_SIZE"
[(ngModel)]="gridCols"
(ngModelChange)="applyGridSize()"
/>
</mat-form-field>
</div>
</div>
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
<span><span class="legend-swatch bg-[#FF5252]"></span> {{ 'FOUR_COLOR.COLOR_1' | translate }}</span>
<span><span class="legend-swatch bg-[#448AFF]"></span> {{ 'FOUR_COLOR.COLOR_2' | translate }}</span>
<span><span class="legend-swatch bg-[#4CAF50]"></span> {{ 'FOUR_COLOR.COLOR_3' | translate }}</span>
<span><span class="legend-swatch bg-[#FFEB3B]"></span> {{ 'FOUR_COLOR.COLOR_4' | translate }}</span>
</div>
<div class="mt-5 flex gap-2.5 py-2.5 px-4 rounded border-l-[5px] font-medium min-w-[300px] items-center"
[ngClass]="{
'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 class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
<canvas #fourColorCanvas
class="cursor-pointer max-w-full h-auto [image-rendering:pixelated]"
(mousedown)="onMouseDown($event)"
(mousemove)="onMouseMove($event)"
(touchstart)="onTouchStart($event)"
(touchmove)="onTouchMove($event)"
></canvas>
</div>
</mat-card-content>
</mat-card>

View File

@@ -0,0 +1,374 @@
import {AfterViewInit, Component, ElementRef, inject, ViewChild} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatCardModule} from '@angular/material/card';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {TranslateModule, TranslateService} from '@ngx-translate/core';
import {MatSnackBar} from '@angular/material/snack-bar';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorNode, Region} from './four-color.models';
import {AlgorithmInformation} from '../information/information.models';
import {Information} from '../information/information';
import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
import {SharedFunctions} from '../../../shared/SharedFunctions';
import {UrlConstants} from '../../../constants/UrlConstants';
@Component({
selector: 'app-four-color',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
TranslateModule,
Information
],
templateUrl: './four-color.component.html',
styleUrl: './four-color.component.scss'
})
export class FourColorComponent implements AfterViewInit {
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
algoInformation: AlgorithmInformation = {
title: 'FOUR_COLOR.EXPLANATION.TITLE',
entries: [
{
name: 'FOUR_COLOR.TITLE',
translateName: true,
description: 'FOUR_COLOR.EXPLANATION.EXPLANATION',
link: UrlConstants.FOUR_COLOR_THEOREM
}
],
disclaimer: 'FOUR_COLOR.EXPLANATION.DISCLAIMER',
disclaimerBottom: 'FOUR_COLOR.EXPLANATION.DISCLAIMER_BOTTOM',
disclaimerListEntry: [
'FOUR_COLOR.EXPLANATION.DISCLAIMER_1',
'FOUR_COLOR.EXPLANATION.DISCLAIMER_2',
'FOUR_COLOR.EXPLANATION.DISCLAIMER_3',
'FOUR_COLOR.EXPLANATION.DISCLAIMER_4'
]
};
gridRows = DEFAULT_GRID_ROWS;
gridCols = DEFAULT_GRID_COLS;
grid: FourColorNode[][] = [];
regions: Region[] = [];
executionTime = 0;
solutionStatus: 'INCOMPLETE' | 'SOLVED' | 'CONFLICTS' | 'INVALID' = 'INCOMPLETE';
@ViewChild('fourColorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private nodeSize = 0;
ngAfterViewInit(): void {
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
this.initializeGrid();
}
applyGridSize(): void {
if (this.gridRows < MIN_GRID_SIZE) this.gridRows = MIN_GRID_SIZE;
if (this.gridRows > MAX_GRID_SIZE) this.gridRows = MAX_GRID_SIZE;
if (this.gridCols < MIN_GRID_SIZE) this.gridCols = MIN_GRID_SIZE;
if (this.gridCols > MAX_GRID_SIZE) this.gridCols = MAX_GRID_SIZE;
this.initializeGrid();
}
initializeGrid(): void {
this.grid = [];
this.solutionStatus = 'INCOMPLETE';
for (let r = 0; r < this.gridRows; r++) {
const row: FourColorNode[] = [];
for (let c = 0; c < this.gridCols; c++) {
row.push({
row: r,
col: c,
regionId: -1,
color: 0,
});
}
this.grid.push(row);
}
this.generateRegions();
this.resizeCanvas();
this.drawGrid();
}
private resizeCanvas(): void {
const canvas = this.canvasRef.nativeElement;
const maxDim = Math.max(this.gridRows, this.gridCols);
this.nodeSize = Math.floor(MAX_GRID_PX / maxDim);
canvas.width = this.gridCols * this.nodeSize;
canvas.height = this.gridRows * this.nodeSize;
}
generateRegions(): void {
const numRegions = Math.floor((this.gridRows * this.gridCols) / 30);
this.regions = [];
const seeds = this.determineSeeds(numRegions);
this.regionGrowth(seeds);
this.determineAdjacency();
}
private determineAdjacency() {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
const currentRegionId = this.grid[row][col].regionId;
const neighbors = this.getNeighbors(row, col);
for (const neighbor of neighbors) {
const neighborRegionId = this.grid[neighbor.row][neighbor.col].regionId;
if (neighborRegionId !== -1 && neighborRegionId !== currentRegionId) {
this.regions[currentRegionId].neighbors.add(neighborRegionId);
this.regions[neighborRegionId].neighbors.add(currentRegionId);
}
}
}
}
}
private regionGrowth(seeds: GridPos[]) {
const queue: GridPos[] = [...seeds];
while (queue.length > 0) {
const {row, col} = queue.shift()!;
const regionId = this.grid[row][col].regionId;
const neighbors = this.getNeighbors(row, col);
for (const neighbor of neighbors) {
if (this.grid[neighbor.row][neighbor.col].regionId === -1) {
this.grid[neighbor.row][neighbor.col].regionId = regionId;
queue.push(neighbor);
}
}
}
}
private determineSeeds(numRegions: number) {
const seeds: GridPos[] = [];
for (let i = 0; i < numRegions; i++) {
let r = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
let c = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
while (this.grid[r][c].regionId !== -1) {
r = SharedFunctions.randomIntFromInterval(0, this.gridRows - 1);
c = SharedFunctions.randomIntFromInterval(0, this.gridCols - 1);
}
this.grid[r][c].regionId = i;
seeds.push({row: r, col: c});
this.regions.push({id: i, color: 0, neighbors: new Set<number>()});
}
return seeds;
}
private getNeighbors(row: number, col: number): GridPos[] {
const res: GridPos[] = [];
if (row > 0) res.push({row: row - 1, col});
if (row < this.gridRows - 1) res.push({row: row + 1, col});
if (col > 0) res.push({row, col: col - 1});
if (col < this.gridCols - 1) res.push({row, col: col + 1});
return res;
}
drawGrid(): void {
if (!this.ctx) return;
this.ctx.clearRect(0, 0, this.canvasRef.nativeElement.width, this.canvasRef.nativeElement.height);
// 1. Draw Cell Backgrounds
for (let r = 0; r < this.gridRows; r++) {
for (let c = 0; c < this.gridCols; c++) {
const node = this.grid[r][c];
this.ctx.fillStyle = this.getNodeColor(node);
this.ctx.fillRect(c * this.nodeSize, r * this.nodeSize, this.nodeSize, this.nodeSize);
}
}
// 2. Draw Region Borders
this.ctx.strokeStyle = '#000';
this.ctx.lineWidth = 2;
this.ctx.beginPath();
for (let r = 0; r < this.gridRows; r++) {
for (let c = 0; c < this.gridCols; c++) {
const currentRegion = this.grid[r][c].regionId;
// Right border
if (c < this.gridCols - 1 && this.grid[r][c+1].regionId !== currentRegion) {
this.ctx.moveTo((c + 1) * this.nodeSize, r * this.nodeSize);
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
}
// Bottom border
if (r < this.gridRows - 1 && this.grid[r+1][c].regionId !== currentRegion) {
this.ctx.moveTo(c * this.nodeSize, (r + 1) * this.nodeSize);
this.ctx.lineTo((c + 1) * this.nodeSize, (r + 1) * this.nodeSize);
}
}
}
this.ctx.stroke();
// 3. Draw Outer Border
this.ctx.strokeStyle = '#000';
this.ctx.lineWidth = 2;
this.ctx.strokeRect(0, 0, this.gridCols * this.nodeSize, this.gridRows * this.nodeSize);
}
private getNodeColor(node: FourColorNode): string {
switch (node.color) {
case 1: return '#FF5252'; // Red
case 2: return '#448AFF'; // Blue
case 3: return '#4CAF50'; // Green
case 4: return '#FFEB3B'; // Yellow
default: return 'white';
}
}
onMouseDown(event: MouseEvent): void {
const pos = this.getGridPos(event);
if (pos) this.handleInteraction(pos);
}
onMouseMove(event: MouseEvent): void {
if (event.buttons !== 1){
return;
}
this.getGridPos(event);
}
onTouchStart(event: TouchEvent): void {
event.preventDefault();
const touch = event.touches[0];
const pos = this.getGridPos(touch);
if (pos) this.handleInteraction(pos);
}
onTouchMove(event: TouchEvent): void {
event.preventDefault();
}
private getGridPos(event: MouseEvent | Touch): GridPos | null {
const rect = this.canvasRef.nativeElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const col = Math.floor(x / (rect.width / this.gridCols));
const row = Math.floor(y / (rect.height / this.gridRows));
if (row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols) {
return {row, col};
}
return null;
}
private handleInteraction(pos: GridPos): void {
const node = this.grid[pos.row][pos.col];
if (node.regionId === -1){
return;
}
const region = this.regions[node.regionId];
region.color = (region.color % 4) + 1;
this.updateRegionColors(region);
this.checkSolution();
this.drawGrid();
}
private updateRegionColors(region: Region): void {
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
if (this.grid[row][col].regionId === region.id) {
this.grid[row][col].color = region.color;
}
}
}
}
resetColors(): void {
for (const region of this.regions) {
region.color = 0;
this.updateRegionColors(region);
}
this.solutionStatus = 'INCOMPLETE';
this.drawGrid();
}
autoSolve(): void {
const startTime = performance.now();
this.resetColors();
const success = this.backtrackSolve(0);
const endTime = performance.now();
this.executionTime = endTime - startTime;
if (success) {
this.checkSolution();
this.drawGrid();
} else {
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
this.snackBar.open(message, 'ALERT');
}
}
private backtrackSolve(regionIndex: number): boolean {
if (regionIndex === this.regions.length) return true;
const region = this.regions[regionIndex];
const availableColors = [1, 2, 3, 4];
for (const color of availableColors) {
if (this.isColorValid(region, color)) {
region.color = color;
this.updateRegionColors(region);
if (this.backtrackSolve(regionIndex + 1)) {
return true;
}
region.color = 0;
//this.updateRegionColors(region);
}
}
return false;
}
private isColorValid(region: Region, color: number): boolean {
for (const neighborId of region.neighbors) {
if (this.regions[neighborId].color === color){
return false;
}
}
return true;
}
checkSolution(): void {
let allColored = true;
let hasConflicts = false;
for (const region of this.regions) {
if (region.color === 0) {
allColored = false;
}
if (region.color > 0 && !this.isColorValid(region, region.color)) {
hasConflicts = true;
}
}
if (hasConflicts) {
this.solutionStatus = allColored ? 'INVALID' : 'CONFLICTS';
} else {
this.solutionStatus = allColored ? 'SOLVED' : 'INCOMPLETE';
}
}
generateNewMap(): void {
this.initializeGrid();
}
}

View File

@@ -0,0 +1,18 @@
export interface FourColorNode {
row: number;
col: number;
regionId: number;
color: number;
}
export interface Region {
id: number;
color: number;
neighbors: Set<number>;
}
export const DEFAULT_GRID_ROWS = 25;
export const DEFAULT_GRID_COLS = 25;
export const MIN_GRID_SIZE = 20;
export const MAX_GRID_SIZE = 38;
export const MAX_GRID_PX = 600;

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

@@ -33,9 +33,11 @@
"EDUCATION": "Ausbildung" "EDUCATION": "Ausbildung"
}, },
"SKILLS": { "SKILLS": {
"JAVA": "Java 8/21+", "JAVA": "Java",
"SPRING": "Spring Boot 2/3", "SPRING": "Spring Boot",
"ANGULAR": "Angular 19+", "ANGULAR": "Angular",
"VUE": "Vue.js",
"REACT": "React",
"DOCKER": "Docker", "DOCKER": "Docker",
"UNITY": "Unity", "UNITY": "Unity",
"PYTHON": "Python", "PYTHON": "Python",
@@ -63,8 +65,13 @@
"XP": { "XP": {
"COMPANY9": { "COMPANY9": {
"COMPANY": "ColorDigital GmbH", "COMPANY": "ColorDigital GmbH",
"ROLE": "Senior Software Engineer", "ROLE": "Senior Software Engineer - DMIxOS Team",
"TIME": "März 2026 heute" "TIME": "März 2026 heute",
"HIGHLIGHTS": {
"P1": "Konzeption und Implementierung der zentralen Cloud-Architektur.",
"P2": "Full-Stack-Entwicklung und Optimierung neuer sowie bestehender Systemkomponenten.",
"P3": "Technische Integration von Partnerunternehmen an das eigene Ökosystem."
}
}, },
"COMPANY8": { "COMPANY8": {
"COMPANY": "Teraport GmbH", "COMPANY": "Teraport GmbH",
@@ -203,7 +210,7 @@
}, },
"TRIBBLE": { "TRIBBLE": {
"TITLE": "Homeserver 'Tribble'", "TITLE": "Homeserver 'Tribble'",
"DESCRIPTION": "In diesem Projekt geht es um die Einrichtung und Wartung meines eigenen Homeservers. Er betreibt mehrere Docker-Container wie Gitea, Jellyfin und mehr. Es ist eine großartige Lernerfahrung im Bereich Self-Hosting und Systemadministration.", "DESCRIPTION": "In diesem Projekt geht es um die Einrichtung und Wartung meines eigenen Homeservers. Er betreibt mehrere Docker-Container wie Gitea, Jellyfin and more. Es ist eine großartige Lernerfahrung im Bereich Self-Hosting und Systemadministration.",
"LINK_INTERNAL": "Projektdetails", "LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Self-Hosting verschiedener Dienste mit Docker.", "P1": "Self-Hosting verschiedener Dienste mit Docker.",
@@ -266,7 +273,7 @@
"BULLET_1": "Entwicklung mit Angular 19+ und Material Design.", "BULLET_1": "Entwicklung mit Angular 19+ und Material Design.",
"BULLET_2": "Implementierung performanter Visualisierungen (WebGPU, Shader, Canvas).", "BULLET_2": "Implementierung performanter Visualisierungen (WebGPU, Shader, Canvas).",
"BULLET_3": "Automatisierte CI/CD-Pipelines und Containerisierung mit Docker.", "BULLET_3": "Automatisierte CI/CD-Pipelines und Containerisierung mit Docker.",
"BULLET_4": "Internationalisierung (i18n) für globale Reichweite.", "BULLET_4": "Internationalization (i18n) für globale Reichweite.",
"CHALLENGE_1": "Optimierung der Render-Performance bei komplexen 3D-Fraktalen in Echtzeit.", "CHALLENGE_1": "Optimierung der Render-Performance bei komplexen 3D-Fraktalen in Echtzeit.",
"CHALLENGE_2": "Architektur einer skalierbaren und wartbaren Frontend-Struktur für diverse Sub-Projekte.", "CHALLENGE_2": "Architektur einer skalierbaren und wartbaren Frontend-Struktur für diverse Sub-Projekte.",
"LEARNING_1": "Effektives UI/UX-Design für komplexe datengesteuerte Visualisierungen.", "LEARNING_1": "Effektives UI/UX-Design für komplexe datengesteuerte Visualisierungen.",
@@ -303,7 +310,7 @@
"TITLE": "Rapid Prototyping & Game Jams", "TITLE": "Rapid Prototyping & Game Jams",
"SHORT_DESCRIPTION": "Sammlung innovativer Spielkonzepte, entstanden in unter 48 Stunden.", "SHORT_DESCRIPTION": "Sammlung innovativer Spielkonzepte, entstanden in unter 48 Stunden.",
"INTRODUCTION": "Teilnahme an nationalen Wettbewerben (z.B. Beansjam). Hier geht es darum, unter extremem Zeitdruck funktionale und spaßige Prototypen zu erschaffen.", "INTRODUCTION": "Teilnahme an nationalen Wettbewerben (z.B. Beansjam). Hier geht es darum, unter extremem Zeitdruck funktionale und spaßige Prototypen zu erschaffen.",
"BULLET_1": "Fokus auf 'Core Game Loop' und schnelles Feedback.", "BULLET_1": "Fokus on 'Core Game Loop' und schnelles Feedback.",
"BULLET_2": "Kollaborative Entwicklung in kleinen, agilen Teams.", "BULLET_2": "Kollaborative Entwicklung in kleinen, agilen Teams.",
"BULLET_3": "Effektives Zeitmanagement und Scope-Kontrolle.", "BULLET_3": "Effektives Zeitmanagement und Scope-Kontrolle.",
"BULLET_4": "Veröffentlichung und Iteration basierend auf Community-Votings.", "BULLET_4": "Veröffentlichung und Iteration basierend auf Community-Votings.",
@@ -430,7 +437,7 @@
"MANDELBROT_EXPLANATION": "basiert auf der iterativen Formel 'z_{n+1} = z_n^2 + c'. Sie prüft für jeden Punkt in der komplexen Ebene, ob die Zahlenfolge stabil bleibt oder ins Unendliche entkommt. Vorteil: Gilt als 'Apfelmännchen' und Mutter der Fraktale. Sie bietet eine unendliche Vielfalt an selbstähnlichen Strukturen, in die man ewig hineinzoomen kann.", "MANDELBROT_EXPLANATION": "basiert auf der iterativen Formel 'z_{n+1} = z_n^2 + c'. Sie prüft für jeden Punkt in der komplexen Ebene, ob die Zahlenfolge stabil bleibt oder ins Unendliche entkommt. Vorteil: Gilt als 'Apfelmännchen' und Mutter der Fraktale. Sie bietet eine unendliche Vielfalt an selbstähnlichen Strukturen, in die man ewig hineinzoomen kann.",
"JULIA_EXPLANATION": "nutzt dieselbe Formel wie Mandelbrot, fixiert jedoch den Parameter 'c' und variiert den Startwert. Je nach Wahl von 'c' entstehen filigrane, wolkenartige Gebilde oder zusammenhanglose 'Staubwolken'. Vorteil: Ermöglicht eine enorme ästiehetische Varianz, da jede Koordinate der Mandelbrot-Menge ein völlig eigenes, einzigartiges Julia-Fraktal erzeugt.", "JULIA_EXPLANATION": "nutzt dieselbe Formel wie Mandelbrot, fixiert jedoch den Parameter 'c' und variiert den Startwert. Je nach Wahl von 'c' entstehen filigrane, wolkenartige Gebilde oder zusammenhanglose 'Staubwolken'. Vorteil: Ermöglicht eine enorme ästiehetische Varianz, da jede Koordinate der Mandelbrot-Menge ein völlig eigenes, einzigartiges Julia-Fraktal erzeugt.",
"NEWTON_EXPLANATION": "entsteht durch die Visualisierung des Newton-Verfahrens zur Nullstellen-Suche einer komplexen Funktion. Jeder Pixel wird danach eingefärbt, zu welcher Nullstelle der Algorithmus konvergiert. Vorteil: Erzeugt faszinierende, sternförmige Symmetrien und komplexe Grenzen, an denen sich die Einzugsgebiete der Nullstellen auf chaotische Weise treffen.", "NEWTON_EXPLANATION": "entsteht durch die Visualisierung des Newton-Verfahrens zur Nullstellen-Suche einer komplexen Funktion. Jeder Pixel wird danach eingefärbt, zu welcher Nullstelle der Algorithmus konvergiert. Vorteil: Erzeugt faszinierende, sternförmige Symmetrien und komplexe Grenzen, an denen sich die Einzugsgebiete der Nullstellen auf chaotische Weise treffen.",
"BURNING_SHIP_EXPLANATION": "ist eine Variation des Mandelbrots, bei der vor jedem Iterationsschritt der Absolutbetrag der Real- und Imaginärteile genommen wird: '(|Re(z)| + i|Im(z)|)^2 + c'. Vorteil: Erzeugt eine markante, asymmetrische Struktur, die einem brennenden Schiff mit Segeln ähnelt. Das Fraktal wirkt düsterer und 'mechanischer' als die klassischen Mengen.", "BURNING_SHIP_EXPLANATION": "ist eine variation des Mandelbrots, bei der vor jedem Iterationsschritt der Absolutbetrag der Real- und Imaginärteile genommen wird: '(|Re(z)| + i|Im(z)|)^2 + c'. Vorteil: Erzeugt eine markante, asymmetrische Struktur, die einem brennenden Schiff mit Segeln ähnelt. Das Fraktal wirkt düsterer und 'mechanischer' als die klassischen Mengen.",
"DISCLAIMER": "Alle diese Fraktale basieren auf dem Prinzip der Iteration und dem Chaos-Effekt. Das bedeutet für deine Visualisierung:", "DISCLAIMER": "Alle diese Fraktale basieren auf dem Prinzip der Iteration und dem Chaos-Effekt. Das bedeutet für deine Visualisierung:",
"DISCLAIMER_1": "Unendliche Tiefe: Egal wie weit du hineinzoomst, es erscheinen immer neue, komplexe Strukturen, die dem Ganzen oft ähneln (Selbstähnlichkeit).", "DISCLAIMER_1": "Unendliche Tiefe: Egal wie weit du hineinzoomst, es erscheinen immer neue, komplexe Strukturen, die dem Ganzen oft ähneln (Selbstähnlichkeit).",
"DISCLAIMER_2": "Fluchtzeit-Algorithmus: Die Farben geben meist an, wie schnell eine Folge einen bestimmten Schwellenwert überschreitet je schneller, desto 'heißer' oder heller die Farbe.", "DISCLAIMER_2": "Fluchtzeit-Algorithmus: Die Farben geben meist an, wie schnell eine Folge einen bestimmten Schwellenwert überschreitet je schneller, desto 'heißer' oder heller die Farbe.",
@@ -501,13 +508,14 @@
"DATA_STRUCTURES_EXPLANATION": "Für maximale GPU-Performance müssen Daten speicherfreundlich ausgerichtet werden (16-Byte-Alignment). Anstatt viele einzelne Variablen zu nutzen, packt man Informationen clever in 4er-Blöcke (vec4). Ein Vertex speichert so z. B. [X, Y, Z, Inverse_Masse]. Hat ein Punkt die inverse Masse 0.0, wird er vom Algorithmus ignoriert und schwebt unbeweglich in der Luft ein eleganter Trick für Aufhängungen ohne extra Wenn-Dann-Abfragen.", "DATA_STRUCTURES_EXPLANATION": "Für maximale GPU-Performance müssen Daten speicherfreundlich ausgerichtet werden (16-Byte-Alignment). Anstatt viele einzelne Variablen zu nutzen, packt man Informationen clever in 4er-Blöcke (vec4). Ein Vertex speichert so z. B. [X, Y, Z, Inverse_Masse]. Hat ein Punkt die inverse Masse 0.0, wird er vom Algorithmus ignoriert und schwebt unbeweglich in der Luft ein eleganter Trick für Aufhängungen ohne extra Wenn-Dann-Abfragen.",
"DISCLAIMER": "XPBD vs. Masse-Feder-Systeme: In der physikalischen Simulation gibt es grundlegende Architektur-Unterschiede beim Lösen der Gleichungen:", "DISCLAIMER": "XPBD vs. Masse-Feder-Systeme: In der physikalischen Simulation gibt es grundlegende Architektur-Unterschiede beim Lösen der Gleichungen:",
"DISCLAIMER_1": "Klassische Masse-Feder-Systeme: Hier werden Kräfte (Hookesches Gesetz) berechnet, die zu Beschleunigungen und schließlich zu neuen Positionen führen. Es gibt zwei Wege, diese mathematisch in die Zukunft zu rechnen (Integration):", "DISCLAIMER_1": "Klassische Masse-Feder-Systeme: Hier werden Kräfte (Hookesches Gesetz) berechnet, die zu Beschleunigungen und schließlich zu neuen Positionen führen. Es gibt zwei Wege, diese mathematisch in die Zukunft zu rechnen (Integration):",
"DISCLAIMER_2": "Explizite Löser (z.B. Forward Euler): Sie berechnen den nächsten Schritt stur aus dem aktuellen Zustand. Sie sind leicht zu programmieren, aber bei steifen Stoffen extrem instabil. Die Kräfte schaukeln sich auf und die Simulation 'explodiert', sofern man keine winzigen, sehr leistungsfressenden Zeitschritte wählt.", "DISCLAIMER_2": "Explizite Löser (z.B. Forward Euler): Sie berechnen den nächsten Schritt stur aus dem aktuellen Zustand. Sie sind leicht zu programmieren, aber bei steifen Stoffen extrem instabil. Die Kräfte schaukeln sich auf und die simulation 'explodiert', sofern man keine winzigen, sehr leistungsfressenden Zeitschritte wählt.",
"DISCLAIMER_3": "Implizite Löser (z.B. Backward Euler): Sie berechnen den nächsten Schritt basierend auf dem zukünftigen Zustand. Das ist mathematisch enorm stabil, erfordert aber das Lösen riesiger globaler Matrix-Gleichungssysteme in jedem Frame. Dies ist auf der GPU schwerer zu parallelisieren und bricht zusammen, wenn sich die Struktur ändert (z. B. durch Zerschneiden des Stoffs).", "DISCLAIMER_3": "Implizite Löser (z.B. Backward Euler): Sie berechnen den nächsten Schritt basierend auf dem zukünftigen Zustand. Das ist mathematisch enorm stabil, erfordert aber das Lösen riesiger globaler Matrix-Gleichungssysteme in jedem Frame. Dies ist auf der GPU schwerer zu parallelisieren und bricht zusammen, wenn sich die Struktur ändert (z. B. durch Zerschneiden des Stoffs).",
"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",
@@ -527,6 +535,10 @@
"TITLE": "Labyrinth-Erzeugung", "TITLE": "Labyrinth-Erzeugung",
"DESCRIPTION": "Visualisierung verschiedener Laybrinth-Erzeugungs-Algorithmen." "DESCRIPTION": "Visualisierung verschiedener Laybrinth-Erzeugungs-Algorithmen."
}, },
"FOUR_COLOR": {
"TITLE": "Vier-Farben-Satz",
"DESCRIPTION": "Der Vier-Farben-Satz besagt, dass jede Landkarte in der Ebene mit maximal vier Farben so eingefärbt werden kann, dass keine zwei aneinandergrenzenden Gebiete dieselbe Farbe besitzen."
},
"FRACTAL": { "FRACTAL": {
"TITLE": "Fraktale", "TITLE": "Fraktale",
"DESCRIPTION": "Visualisierung von komplexe, geometrische Mustern, die sich selbst in immer kleineren Maßstäben ähneln (Selbstähnlichkeit)." "DESCRIPTION": "Visualisierung von komplexe, geometrische Mustern, die sich selbst in immer kleineren Maßstäben ähneln (Selbstähnlichkeit)."
@@ -546,5 +558,35 @@
"NOTE": "HINWEIS", "NOTE": "HINWEIS",
"GRID_HEIGHT": "Höhe", "GRID_HEIGHT": "Höhe",
"GRID_WIDTH": "Beite" "GRID_WIDTH": "Beite"
},
"FOUR_COLOR": {
"TITLE": "Vier-Farben-Satz",
"GENERATE": "Neue Karte generieren",
"SOLVE": "Automatisch lösen",
"CLEAR": "Farben zurücksetzen",
"COLOR_1": "Farbe 1",
"COLOR_2": "Farbe 2",
"COLOR_3": "Farbe 3",
"COLOR_4": "Farbe 4",
"EXECUTION_TIME": "Ausführungszeit",
"STATUS": {
"LABEL": "Status",
"INCOMPLETE": "Die Karte ist noch nicht vollständig eingefärbt.",
"SOLVED": "Glückwunsch! Du hast die Karte korrekt gelöst!",
"CONFLICTS": "Achtung: Benachbarte Regionen haben die gleiche Farbe!",
"INVALID": "Karte ist vollständig, enthält aber Fehler."
},
"EXPLANATION": { "TITLE": "Der Vier-Farben-Satz",
"EXPLANATION": "Der Vier-Farben-Satz besagt, dass jede Landkarte in der Ebene mit maximal vier Farben so eingefärbt werden kann, dass keine zwei aneinandergrenzenden Gebiete dieselbe Farbe besitzen.",
"DISCLAIMER": "Dieser Algorithmus verwendet Backtracking, um eine gültige Färbung für die generierten Regionen zu finden.",
"DISCLAIMER_1": "Kartengenerierung: Regionen werden mittels eines zufälligen Seed-Wachstumsalgorithmus (Voronoi-ähnlich) auf einem Gitter erzeugt.",
"DISCLAIMER_2": "Adjazenz: Zwei Regionen gelten als benachbart, wenn sie mindestens eine gemeinsame Kante im Gitter teilen.",
"DISCLAIMER_3": "Backtracking: Der Löser probiert die Farben 1-4 für jede Region aus und macht Schritte rückgängig (Backtracking), wenn ein Konflikt auftritt.",
"DISCLAIMER_4": "Interaktiv: Sie können auch auf Regionen klicken, um manuell durch die Farben zu wechseln.",
"DISCLAIMER_BOTTOM": "Zum einfärben in die Zelle klicken. Durch erneutes klicken ändert sich die Farbe."
},
"ALERT": {
"NO_SOLUTION": "Keine Lösung gefunden (das sollte bei einer planaren Karte nicht passieren!)."
}
} }
} }

View File

@@ -33,9 +33,11 @@
"EDUCATION": "Education" "EDUCATION": "Education"
}, },
"SKILLS": { "SKILLS": {
"JAVA": "Java 8/21+", "JAVA": "Java",
"SPRING": "Spring Boot 2/3", "SPRING": "Spring Boot",
"ANGULAR": "Angular 19+", "ANGULAR": "Angular",
"VUE": "Vue.js",
"REACT": "React",
"DOCKER": "Docker", "DOCKER": "Docker",
"UNITY": "Unity", "UNITY": "Unity",
"PYTHON": "Python", "PYTHON": "Python",
@@ -63,8 +65,13 @@
"XP": { "XP": {
"COMPANY9": { "COMPANY9": {
"COMPANY": "ColorDigital GmbH", "COMPANY": "ColorDigital GmbH",
"ROLE": "Senior Software Engineer", "ROLE": "Senior Software Engineer - DMIxOS Team",
"TIME": "Mar. 2026 now" "TIME": "March 2026 present",
"HIGHLIGHTS": {
"P1": "Designed and implemented the core cloud architecture.",
"P2": "Full-stack development and optimization of new and existing system components.",
"P3": "Technical integration of partner companies into the proprietary ecosystem."
}
}, },
"COMPANY8": { "COMPANY8": {
"COMPANY": "Teraport GmbH", "COMPANY": "Teraport GmbH",
@@ -505,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",
@@ -526,6 +534,10 @@
"TITLE": "Maze Generation", "TITLE": "Maze Generation",
"DESCRIPTION": "Visualizing various maze generation algorithms." "DESCRIPTION": "Visualizing various maze generation algorithms."
}, },
"FOUR_COLOR": {
"TITLE": "Four Color Theorem",
"DESCRIPTION": "The four color theorem states that any map in a plane can be colored using at most four colors in such a way that regions sharing a common boundary (other than a single point) do not share the same color."
},
"FRACTAL": { "FRACTAL": {
"TITLE": "Fractals", "TITLE": "Fractals",
"DESCRIPTION": "Visualisation of complex geometric patterns that resemble each other on increasingly smaller scales (self-similarity)." "DESCRIPTION": "Visualisation of complex geometric patterns that resemble each other on increasingly smaller scales (self-similarity)."
@@ -545,5 +557,35 @@
"NOTE": "Note", "NOTE": "Note",
"GRID_HEIGHT": "Height", "GRID_HEIGHT": "Height",
"GRID_WIDTH": "Width" "GRID_WIDTH": "Width"
},
"FOUR_COLOR": {
"TITLE": "Four Color Theorem",
"GENERATE": "Generate Map",
"SOLVE": "Auto Solve",
"CLEAR": "Clear Colors",
"COLOR_1": "Color 1",
"COLOR_2": "Color 2",
"COLOR_3": "Color 3",
"COLOR_4": "Color 4",
"EXECUTION_TIME": "Execution Time",
"STATUS": {
"LABEL": "Status",
"INCOMPLETE": "Map is not fully colored yet.",
"SOLVED": "Congratulations! You solved the map correctly!",
"CONFLICTS": "Warning: Adjacent regions have the same color!",
"INVALID": "Map is fully colored, but contains conflicts."
},
"EXPLANATION": { "TITLE": "Four Color Theorem",
"EXPLANATION": "The four color theorem states that any map in a plane can be colored using at most four colors in such a way that regions sharing a common boundary (other than a single point) do not share the same color.",
"DISCLAIMER": "This algorithm uses backtracking to find a valid coloring for the generated regions.",
"DISCLAIMER_1": "Map Generation: Regions are generated using a random seed growth algorithm (Voronoi-like) on a grid.",
"DISCLAIMER_2": "Adjacency: Two regions are considered neighbors if they share at least one edge in the grid.",
"DISCLAIMER_3": "Backtracking: The solver tries colors 1-4 for each region, backtracking when a conflict is found.",
"DISCLAIMER_4": "Interactive: You can also click on regions to cycle through colors manually.",
"DISCLAIMER_BOTTOM": "Click to color the region. Click again to change the color."
},
"ALERT": {
"NO_SOLUTION": "No solution found (this should not happen for a planar map!)."
}
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

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,235 +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;
}
}
}
.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;
@@ -477,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;
@@ -850,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' },
},
},
},
};