Compare commits
15 Commits
40c3b9dd5a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ffe7bbb25e | |||
|
|
9a6e91ea9d | ||
|
|
f9797493ce | ||
|
|
a349f630c6 | ||
|
|
4e24cb5df1 | ||
|
|
0d3411aeb2 | ||
| 9a6b5198ad | |||
|
|
2d82cdfd6b | ||
|
|
00ebedfc90 | ||
| 4bf5b3e2c4 | |||
| ab730f8f3b | |||
| 0104bad59f | |||
| 54b33daa40 | |||
| 61defae20e | |||
| 64842d388b |
@@ -92,12 +92,18 @@ jobs:
|
||||
echo "branch=$BRANCH" >> "$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/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
registry: git.andreas-dahm.eu
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.TOKEN_GITEA }}
|
||||
|
||||
- name: Build & Push (branch + sha tags)
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -105,8 +111,8 @@ jobs:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/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 }}
|
||||
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
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -114,4 +120,4 @@ jobs:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/playground:frontend-a-main
|
||||
git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/playground:frontend-a-main
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -44,3 +44,7 @@ Thumbs.db
|
||||
|
||||
# Lighthouse
|
||||
.lighthouseci/
|
||||
.claude/settings.local.json
|
||||
|
||||
# claude
|
||||
.claude/
|
||||
|
||||
@@ -77,7 +77,7 @@ ng build
|
||||
|
||||
* **Language:** TypeScript
|
||||
* **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).
|
||||
* **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files.
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ To build and run the application using Docker locally:
|
||||
|
||||
* **Language:** TypeScript
|
||||
* **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).
|
||||
* **Internationalization:** Uses `ngx-translate` with `en.json` and `de.json` asset files.
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/tailwind.css",
|
||||
"src/styles.scss",
|
||||
"node_modules/swiper/swiper-bundle.css"
|
||||
]
|
||||
@@ -95,6 +96,7 @@
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/tailwind.css",
|
||||
"src/styles.scss"
|
||||
]
|
||||
}
|
||||
|
||||
4134
package-lock.json
generated
4134
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -12,15 +12,15 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular-slider/ngx-slider": "^21.0.0",
|
||||
"@angular/animations": "~21.2.1",
|
||||
"@angular/cdk": "~21.2.1",
|
||||
"@angular/common": "~21.2.1",
|
||||
"@angular/compiler": "~21.2.1",
|
||||
"@angular/core": "~21.2.1",
|
||||
"@angular/forms": "~21.2.1",
|
||||
"@angular/material": "~21.2.1",
|
||||
"@angular/platform-browser": "~21.2.1",
|
||||
"@angular/router": "~21.2.1",
|
||||
"@angular/animations": "~21.2.9",
|
||||
"@angular/cdk": "~21.2.6",
|
||||
"@angular/common": "~21.2.9",
|
||||
"@angular/compiler": "~21.2.9",
|
||||
"@angular/core": "~21.2.9",
|
||||
"@angular/forms": "~21.2.9",
|
||||
"@angular/material": "~21.2.6",
|
||||
"@angular/platform-browser": "~21.2.9",
|
||||
"@angular/router": "~21.2.9",
|
||||
"@babylonjs/core": "^8.54.1",
|
||||
"@ngx-translate/core": "^17.0.0",
|
||||
"@ngx-translate/http-loader": "^17.0.0",
|
||||
@@ -30,16 +30,18 @@
|
||||
"tslib": "~2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "~21.2.1",
|
||||
"@angular/cli": "~21.2.1",
|
||||
"@angular/compiler-cli": "~21.2.1",
|
||||
"@angular/build": "~21.2.7",
|
||||
"@angular/cli": "~21.2.7",
|
||||
"@angular/compiler-cli": "~21.2.9",
|
||||
"@eslint/js": "~10.0.1",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@types/jasmine": "~6.0.0",
|
||||
"angular-eslint": "21.3.0",
|
||||
"angular-eslint": "21.3.1",
|
||||
"eslint": "^10.0.3",
|
||||
"jasmine-core": "~6.1.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "8.56.1"
|
||||
"typescript-eslint": "8.58.2"
|
||||
},
|
||||
"overrides": {
|
||||
"tmp": "^0.2.3"
|
||||
|
||||
@@ -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.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.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent) },
|
||||
];
|
||||
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
LINK: '/algorithms/cloth',
|
||||
};
|
||||
|
||||
static readonly FOUR_COLOR = {
|
||||
PATH: 'algorithms/four_color',
|
||||
LINK: '/algorithms/four_color',
|
||||
};
|
||||
|
||||
static readonly IMPRINT = {
|
||||
PATH: 'imprint',
|
||||
LINK: '/imprint',
|
||||
|
||||
@@ -23,5 +23,6 @@
|
||||
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 DATA_STRUCTURE_WIKI = 'https://de.wikipedia.org/wiki/Datenstruktur'
|
||||
static readonly FOUR_COLOR_THEOREM = 'https://de.wikipedia.org/wiki/Vier-Farben-Satz'
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<app-particles-background></app-particles-background>
|
||||
<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 />
|
||||
</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>
|
||||
</footer>
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
<mat-toolbar class="topbar" color="primary" (keydown)="onKeydown($event)">
|
||||
<a class="brand" routerLink="/">
|
||||
<img class="logo-dot" src="{{AssetsConstants.LOGO}}" alt="" aria-hidden="true" draggable="false"
|
||||
<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="flex items-center gap-[clamp(0.4rem,1vw,0.6rem)] text-inherit no-underline" routerLink="/">
|
||||
<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;">
|
||||
<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>
|
||||
|
||||
<nav class="nav">
|
||||
<a [routerLink]="RouterConstants.ABOUT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ABOUT' | translate }}</a>
|
||||
<a [routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a>
|
||||
<a [routerLink]="RouterConstants.ALGORITHMS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ALGORITHMS' | translate }}</a>
|
||||
<a [routerLink]="RouterConstants.IMPRINT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a>
|
||||
<nav class="absolute left-1/2 -translate-x-1/2 flex gap-[clamp(0.25rem,1vw,0.5rem)] justify-center mobile:hidden">
|
||||
<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.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"
|
||||
[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>
|
||||
|
||||
<!-- 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>
|
||||
</button>
|
||||
|
||||
<span class="spacer"></span>
|
||||
<span class="flex-1"></span>
|
||||
|
||||
<!-- Mobile nav menu -->
|
||||
<mat-menu #navMenu="matMenu" xPosition="before">
|
||||
@@ -35,7 +39,7 @@
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<span class="spacer"></span>
|
||||
<span class="flex-1"></span>
|
||||
|
||||
<!-- Settings: Sprache + Theme -->
|
||||
<button mat-icon-button [matMenuTriggerFor]="settingsMenu" aria-label="Open settings"
|
||||
|
||||
@@ -1,107 +1,6 @@
|
||||
/* ---- Topbar Host & Base ---- */
|
||||
:host {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,34 @@
|
||||
<section class="about">
|
||||
<mat-card class="hero">
|
||||
<div class="hero-flex-container">
|
||||
<div class="photo">
|
||||
<img [ngSrc]="AssetsConstants.ME" width="421" height="512" alt="{{ 'ABOUT.ALT.PROFILE' | translate }}"
|
||||
<section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
|
||||
<mat-card>
|
||||
<div class="flex flex-wrap gap-fluid-lg p-fluid-md items-start">
|
||||
<div class="flex-[1_1_min(100%,425px)] max-w-full flex justify-center">
|
||||
<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 />
|
||||
</div>
|
||||
|
||||
<div class="intro">
|
||||
<h1>{{ 'ABOUT.HELLO' | translate }}</h1>
|
||||
<p class="lead">
|
||||
<div class="flex-[999_1_min(100%,400px)]">
|
||||
<h1 class="mt-0 mb-2 text-[clamp(1.5rem,5vw,2.5rem)]">{{ 'ABOUT.HELLO' | translate }}</h1>
|
||||
<p class="opacity-90 my-2 mb-4 text-[clamp(1rem,2.5vw,1.15rem)]">
|
||||
{{ 'ABOUT.LEAD' | translate }}
|
||||
</p>
|
||||
|
||||
<div class="meta">
|
||||
<div class="row">
|
||||
<div class="flex flex-col gap-1 mb-2">
|
||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||
<mat-icon aria-hidden="true">work</mat-icon>
|
||||
<span>{{ 'ABOUT.ROLE' | translate }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||
<mat-icon aria-hidden="true">location_on</mat-icon>
|
||||
<span>{{ 'ABOUT.LOCATION' | translate }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||
<mat-icon aria-hidden="true">email</mat-icon>
|
||||
<a href="" (click)="SharedFunctions.openMail($event)">
|
||||
{{ 'ABOUT.CONTACT_ME' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||
<mat-icon>data_object</mat-icon>
|
||||
<a href="{{UrlConstants.CODEBERG}}" target="_blank" rel="noopener">Codeberg</a>
|
||||
<span>·</span>
|
||||
@@ -39,11 +40,11 @@
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="skills">
|
||||
<h2>{{ 'ABOUT.SECTION.SKILLS' | translate }}</h2>
|
||||
<div class="chip-groups">
|
||||
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.SKILLS' | translate }}</h2>
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,250px),1fr))] gap-fluid-sm ml-1 mb-2">
|
||||
<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">
|
||||
@for (s of skillsArchitecture; track s) {
|
||||
<mat-chip>{{ s | translate }}</mat-chip>
|
||||
@@ -52,7 +53,7 @@
|
||||
</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">
|
||||
@for (s of skillsCore; track s) {
|
||||
<mat-chip>{{ s | translate }}</mat-chip>
|
||||
@@ -61,7 +62,7 @@
|
||||
</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">
|
||||
@for (s of skillsEngineering; track s) {
|
||||
<mat-chip>{{ s | translate }}</mat-chip>
|
||||
@@ -71,31 +72,31 @@
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="experdience">
|
||||
<h2 style="margin-left: 0.5rem;">{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2>
|
||||
<div class="xp-list">
|
||||
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||
<h2 class="mt-1 ml-2 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2>
|
||||
<div class="ml-1 grid gap-fluid-sm">
|
||||
@for (entry of xpKeys; track entry.key) {
|
||||
<div class="xp-item">
|
||||
<div class="xp-head-grid">
|
||||
<div class="logo-wrap">
|
||||
<img src="{{entry.logo}}" alt="" class="company-logo" aria-hidden="true" />
|
||||
<div>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-3">
|
||||
<div class="row-span-2 col-start-1 flex items-center">
|
||||
<img src="{{entry.logo}}" alt="" class="w-10 h-10 object-contain opacity-90 rounded-[10%] bg-logo-bg" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="head-row">
|
||||
<strong>{{ (entry.key + '.ROLE') | translate }}</strong>
|
||||
<span class="time">{{ (entry.key + '.TIME') | translate }}</span>
|
||||
<div class="row-start-1 col-start-2 flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.ROLE') | translate }}</strong>
|
||||
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (entry.key + '.TIME') | translate }}</span>
|
||||
</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 }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@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>
|
||||
<li>{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
|
||||
<li>{{ 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.P1' | translate }}</li>
|
||||
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
|
||||
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@@ -108,38 +109,38 @@
|
||||
}
|
||||
</div>
|
||||
</mat-card>
|
||||
<mat-card class="projects">
|
||||
<h2>{{ 'ABOUT.SECTION.PROJECTS' | translate }}</h2>
|
||||
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||
<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) {
|
||||
<div class="xp-item">
|
||||
<div class="head-row">
|
||||
<strong>{{ (entry.key + '.TITLE') | translate }}</strong>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.TITLE') | translate }}</strong>
|
||||
</div>
|
||||
<div class="company-row">
|
||||
<div class="mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
|
||||
{{ (entry.key + '.DESCRIPTION') | translate }}
|
||||
</div>
|
||||
@if (entry.externalLink) {
|
||||
<div class="link-row">
|
||||
<a class="link-with-icon" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer">
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
<div class="mt-[0.1rem] opacity-85">
|
||||
<a class="inline-flex items-center gap-[0.35rem] leading-none" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer">
|
||||
<mat-icon class="!text-[18px] !w-[18px] !h-[18px]">open_in_new</mat-icon>
|
||||
{{ (entry.key + '.LINK_EXTERNAL') | translate }}
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
<div class="link-row">
|
||||
<a class="link-with-icon" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
|
||||
<div class="mt-[0.1rem] opacity-85">
|
||||
<a class="inline-flex items-center gap-[0.35rem] leading-none" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
|
||||
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 }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="highlights-noMargin">
|
||||
<div>
|
||||
<ul>
|
||||
<li>{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
|
||||
<li>{{ 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.P1' | translate }}</li>
|
||||
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
|
||||
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,23 +153,23 @@
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="education">
|
||||
<h2>{{ 'ABOUT.SECTION.EDUCATION' | translate }}</h2>
|
||||
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EDUCATION' | translate }}</h2>
|
||||
|
||||
<div class="xp-list">
|
||||
<div class="xp-item">
|
||||
<div class="ml-1 grid gap-fluid-sm">
|
||||
<div>
|
||||
@for (entry of educationKeys; track entry.key) {
|
||||
<div class="head-row">
|
||||
<strong>{{ (entry.key + '.WHERE') | translate }}</strong>
|
||||
<span class="time">{{ (entry.key + '.WHEN') | translate }}</span>
|
||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.WHERE') | translate }}</strong>
|
||||
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (entry.key + '.WHEN') | translate }}</span>
|
||||
</div>
|
||||
<div class="company-row">
|
||||
<div class="mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
|
||||
{{ (entry.key + '.WHAT') | translate }}
|
||||
</div>
|
||||
|
||||
@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>
|
||||
|
||||
@@ -33,7 +33,7 @@ export class AboutComponent {
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY9',
|
||||
logo: AssetsConstants.COLORDIGITAL_LOGO,
|
||||
no_highlights: true
|
||||
no_highlights: false
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY8',
|
||||
@@ -134,6 +134,8 @@ export class AboutComponent {
|
||||
'ABOUT.SKILLS.JAVA',
|
||||
'ABOUT.SKILLS.SPRING',
|
||||
'ABOUT.SKILLS.ANGULAR',
|
||||
'ABOUT.SKILLS.VUE',
|
||||
'ABOUT.SKILLS.REACT',
|
||||
'ABOUT.SKILLS.TYPESCRIPT',
|
||||
'ABOUT.SKILLS.CSHARP',
|
||||
'ABOUT.SKILLS.PYTHON'
|
||||
@@ -145,7 +147,9 @@ export class AboutComponent {
|
||||
'ABOUT.TOOLS.DOCKER',
|
||||
'ABOUT.TOOLS.K8S',
|
||||
'ABOUT.TOOLS.JENKINS',
|
||||
'ABOUT.TOOLS.POSTGRES'
|
||||
'ABOUT.TOOLS.POSTGRES',
|
||||
'ABOUT.TOOLS.MONGO',
|
||||
'ABOUT.TOOLS.GITLAB'
|
||||
];
|
||||
|
||||
skillsEngineering = [
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<div class="card-grid">
|
||||
<h1 class="algo-page-title">{{ 'ALGORITHM.TITLE' | translate }}</h1>
|
||||
<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="m-0 mb-2 text-[clamp(1.4rem,4vw,2rem)]">{{ 'ALGORITHM.TITLE' | translate }}</h1>
|
||||
</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) {
|
||||
<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>
|
||||
<div class="algo-icon-wrap">
|
||||
<mat-icon>{{ category.icon }}</mat-icon>
|
||||
<div class="flex items-center text-[var(--mat-sys-primary)] mb-4">
|
||||
<mat-icon class="!text-[26px] !w-[26px] !h-[26px]">{{ category.icon }}</mat-icon>
|
||||
</div>
|
||||
<h3 class="algo-card-title">{{ category.title | translate }}</h3>
|
||||
<p class="algo-card-desc">{{ category.description | translate }}</p>
|
||||
<h3 class="text-[1.05rem] font-semibold mb-2 m-0">{{ category.title | translate }}</h3>
|
||||
<p class="m-0 opacity-75 text-sm leading-relaxed">{{ category.description | translate }}</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@@ -63,6 +63,13 @@ export class AlgorithmsService {
|
||||
description: 'ALGORITHM.CLOTH.DESCRIPTION',
|
||||
routerLink: RouterConstants.CLOTH.LINK,
|
||||
icon: 'texture'
|
||||
},
|
||||
{
|
||||
id: 'fourColor',
|
||||
title: 'ALGORITHM.FOUR_COLOR.TITLE',
|
||||
description: 'ALGORITHM.FOUR_COLOR.DESCRIPTION',
|
||||
routerLink: RouterConstants.FOUR_COLOR.LINK,
|
||||
icon: 'palette'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
48
src/app/pages/algorithms/cloth/cloth-glsl.shader.ts
Normal file
48
src/app/pages/algorithms/cloth/cloth-glsl.shader.ts
Normal 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);
|
||||
}
|
||||
`;
|
||||
@@ -1,11 +1,11 @@
|
||||
<mat-card class="algo-container">
|
||||
<mat-card class="w-full max-w-[1920px] p-5">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'CLOTH.TITLE' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<app-information [algorithmInformation]="algoInformation"/>
|
||||
<div class="controls-container">
|
||||
<div class="controls-panel">
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||
<button mat-raised-button color="primary" (click)="toggleWind()">
|
||||
{{ isWindActive ? ('CLOTH.WIND_OFF' | translate) : ('CLOTH.WIND_ON' | translate) }}
|
||||
</button>
|
||||
@@ -16,8 +16,8 @@
|
||||
{{ 'CLOTH.RESTART_SIMULATION' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="sliders-panel">
|
||||
<label>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation }}</label>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span>{{ 'CLOTH.ELONGATION' | translate }}: {{ elongation }}</span>
|
||||
<mat-slider min="0.5" max="2.0" step="0.1">
|
||||
<input matSliderThumb [(ngModel)]="elongation">
|
||||
</mat-slider>
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
/**
|
||||
* File: cloth.component.ts
|
||||
* Description: Component for cloth simulation using WebGPU compute shaders.
|
||||
*/
|
||||
|
||||
import {Component} from '@angular/core';
|
||||
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 {ClothBuffers, ClothConfig, ClothData, ClothPipelines} from './cloth.model';
|
||||
import {ClothConfig} from './cloth.model';
|
||||
import {Information} from '../information/information';
|
||||
import {AlgorithmInformation} from '../information/information.models';
|
||||
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({
|
||||
selector: 'app-cloth',
|
||||
@@ -43,17 +33,16 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
||||
export class ClothComponent {
|
||||
private currentSceneData: SceneEventData | null = null;
|
||||
private simulationTime: number = 0;
|
||||
private clothMesh: GroundMesh | null = null;
|
||||
private strategy: ClothSimulationStrategy | null = null;
|
||||
|
||||
public isWindActive: boolean = false;
|
||||
public isOutlineActive: boolean = false;
|
||||
public stiffness: number = 80;
|
||||
// Elongation along the vertical (Y) axis, 0.5 = compressed, 2.0 = stretched
|
||||
public elongation: number = 1.0;
|
||||
|
||||
public renderConfig: RenderConfig = {
|
||||
mode: '3D',
|
||||
initialViewSize: 20,
|
||||
shaderLanguage: ShaderLanguage.WGSL
|
||||
initialViewSize: 20
|
||||
};
|
||||
|
||||
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']
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the Babylon scene is ready.
|
||||
* @param event The scene event data.
|
||||
*/
|
||||
public onSceneReady(event: SceneEventData): void {
|
||||
this.currentSceneData = event;
|
||||
this.createSimulation();
|
||||
@@ -104,10 +89,11 @@ export class ClothComponent {
|
||||
|
||||
public toggleMesh(): void {
|
||||
this.isOutlineActive = !this.isOutlineActive;
|
||||
if (!this.clothMesh?.material) {
|
||||
const mesh = this.strategy?.getMesh();
|
||||
if (!mesh?.material) {
|
||||
return;
|
||||
}
|
||||
this.clothMesh.material.wireframe = this.isOutlineActive;
|
||||
mesh.material.wireframe = this.isOutlineActive;
|
||||
}
|
||||
|
||||
public restartSimulation(): void {
|
||||
@@ -115,36 +101,43 @@ export class ClothComponent {
|
||||
this.createSimulation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes and starts the cloth simulation.
|
||||
*/
|
||||
private createSimulation(): void {
|
||||
if (!this.currentSceneData) return;
|
||||
|
||||
const { engine, scene } = this.currentSceneData;
|
||||
|
||||
// 1. Define physics parameters
|
||||
const config = this.getClothConfig();
|
||||
|
||||
// 2. Generate initial CPU data (positions, constraints)
|
||||
const clothData = this.generateClothData(config);
|
||||
|
||||
// 3. Upload to GPU
|
||||
const buffers = this.createStorageBuffers(engine, clothData);
|
||||
|
||||
// 4. Create Compute Shaders
|
||||
const pipelines = this.setupComputePipelines(engine, buffers);
|
||||
|
||||
// 5. Setup Rendering (Mesh, Material, Camera)
|
||||
this.setupRenderMesh(scene, config, buffers.positions);
|
||||
|
||||
// 6. Start the physics loop
|
||||
this.startRenderLoop(engine, scene, config, buffers, pipelines);
|
||||
if (!this.currentSceneData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {engine, scene, gpuTier} = this.currentSceneData;
|
||||
const config = this.getClothConfig();
|
||||
|
||||
if (this.strategy) {
|
||||
this.strategy.dispose();
|
||||
}
|
||||
|
||||
this.strategy = gpuTier === 'webgpu'
|
||||
? new ClothGpuStrategy()
|
||||
: new ClothCpuStrategy();
|
||||
|
||||
this.strategy.init(scene, engine, config);
|
||||
this.startParamUpdateLoop(scene, engine);
|
||||
}
|
||||
|
||||
private startParamUpdateLoop(scene: any, engine: any): void {
|
||||
scene.onAfterRenderObservable.clear();
|
||||
scene.onAfterRenderObservable.add(() => {
|
||||
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 {
|
||||
const gridWidth = 100;
|
||||
const gridHeight = 100;
|
||||
@@ -162,239 +155,4 @@ export class ClothComponent {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// --- SIMULATION CONFIGURATION ---
|
||||
import {ComputeShader, StorageBuffer} from '@babylonjs/core';
|
||||
|
||||
export interface ClothConfig {
|
||||
gridWidth: number;
|
||||
gridHeight: number;
|
||||
@@ -10,27 +7,10 @@ export interface ClothConfig {
|
||||
particleInvMass: number;
|
||||
}
|
||||
|
||||
// --- RAW CPU DATA ---
|
||||
export interface ClothData {
|
||||
positions: Float32Array;
|
||||
prevPositions: Float32Array;
|
||||
velocities: Float32Array;
|
||||
constraints: number[][]; // Array containing the 4 phases
|
||||
constraints: number[][];
|
||||
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;
|
||||
}
|
||||
|
||||
149
src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.ts
Normal file
149
src/app/pages/algorithms/cloth/strategies/cloth-cpu-physics.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
216
src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts
Normal file
216
src/app/pages/algorithms/cloth/strategies/cloth-cpu.strategy.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
275
src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts
Normal file
275
src/app/pages/algorithms/cloth/strategies/cloth-gpu.strategy.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
<mat-card class="algo-container">
|
||||
<mat-card class="w-full max-w-[1920px] p-5">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'GOL.TITLE' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<app-information [algorithmInformation]="algoInformation"/>
|
||||
<div class="controls-container">
|
||||
<div class="controls-panel">
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||
<button mat-raised-button (click)="generate(Scenario.SIMPLE)">
|
||||
<mat-icon>arrow_right</mat-icon> {{ 'GOL.SIMPLE_SCENE' | translate }}
|
||||
</button>
|
||||
@@ -22,7 +22,7 @@
|
||||
<mat-icon>check_box_outline_blank</mat-icon> {{ 'GOL.EMPTY_SCENE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls-panel">
|
||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||
@if (gameStarted())
|
||||
{
|
||||
<button mat-raised-button (click)="pauseGame()">
|
||||
@@ -35,8 +35,8 @@
|
||||
}
|
||||
<p>{{ 'SORTING.EXECUTION_TIME' | translate }}: {{ executionTime }} ms</p>
|
||||
</div>
|
||||
<div class="input-container">
|
||||
<mat-form-field appearance="outline" class="input-field">
|
||||
<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
|
||||
@@ -47,7 +47,7 @@
|
||||
(ngModelChange)="pauseGame(); genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
|
||||
/>
|
||||
</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>
|
||||
<input
|
||||
matInput
|
||||
@@ -58,7 +58,7 @@
|
||||
(ngModelChange)="pauseGame(); genericGridComponent.gridCols = gridCols; genericGridComponent.applyGridSize()"
|
||||
/>
|
||||
</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>
|
||||
<input
|
||||
matInput
|
||||
@@ -72,9 +72,9 @@
|
||||
</mat-form-field>
|
||||
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><span class="legend-color alive"></span> {{ 'GOL.ALIVE' | translate }}</span>
|
||||
<span><span class="legend-color empty"></span> {{ 'GOL.DEAD' | translate }}</span>
|
||||
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
|
||||
<span><span class="legend-swatch bg-black"></span> {{ 'GOL.ALIVE' | translate }}</span>
|
||||
<span><span class="legend-swatch bg-gray-300"></span> {{ 'GOL.DEAD' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<app-generic-grid
|
||||
|
||||
@@ -178,10 +178,9 @@ export class ConwayGolComponent implements AfterViewInit {
|
||||
|
||||
async startGame(): Promise<void> {
|
||||
this.gameStarted.set(true);
|
||||
let lifeIsDead = false;
|
||||
while (this.gameStarted()){
|
||||
const startTime = performance.now();
|
||||
lifeIsDead = true;
|
||||
let lifeIsDead = true;
|
||||
for (let row = 0; row < this.gridRows; row++) {
|
||||
for (let col = 0; col < this.gridCols; col++) {
|
||||
lifeIsDead = this.checkLifeRules(row, col, this.writeGrid) && lifeIsDead;
|
||||
|
||||
@@ -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>
|
||||
374
src/app/pages/algorithms/four-color/four-color.component.ts
Normal file
374
src/app/pages/algorithms/four-color/four-color.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
18
src/app/pages/algorithms/four-color/four-color.models.ts
Normal file
18
src/app/pages/algorithms/four-color/four-color.models.ts
Normal 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;
|
||||
@@ -1,12 +1,12 @@
|
||||
<mat-card class="algo-container">
|
||||
<mat-card class="w-full max-w-[1920px] p-5">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'FRACTAL.TITLE' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<app-information [algorithmInformation]="algoInformation"/>
|
||||
<div class="controls-container">
|
||||
<div class="controls-panel">
|
||||
<mat-form-field appearance="fill">
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||
<mat-form-field class="w-[200px]" appearance="fill">
|
||||
<mat-label>{{ 'FRACTAL.ALGORITHM' | translate }}</mat-label>
|
||||
<mat-select [value]="'Mandelbrot'" (selectionChange)="onAlgorithmChange($event.value)">
|
||||
<mat-option value="Mandelbrot">Mandelbrot</mat-option>
|
||||
@@ -15,7 +15,7 @@
|
||||
<mat-option value="Newton">Newton</mat-option>
|
||||
</mat-select>
|
||||
</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-select [value]="'Blue-Gold'" (selectionChange)="onColorChanged($event.value)">
|
||||
<mat-option value="Blue-Gold">Blue-Gold</mat-option>
|
||||
@@ -29,7 +29,7 @@
|
||||
<mat-icon>undo</mat-icon> {{ 'FRACTAL.RESET' | translate }}
|
||||
</button>
|
||||
</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>
|
||||
<ngx-slider [(value)]="sliderValue" [options]="options" (valueChange)="onSliderChange($event)" ></ngx-slider>
|
||||
<mat-icon>zoom_in</mat-icon>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<mat-card class="algo-container">
|
||||
<mat-card class="w-full max-w-[1920px] p-5">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'FRACTAL3D.TITLE' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<app-information [algorithmInformation]="algoInformation"/>
|
||||
<div class="controls-container">
|
||||
<div class="controls-panel">
|
||||
<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 matButton="filled" (click)="onFractalTypeChange(0)">{{ 'FRACTAL3D.MANDELBULB' | translate }}</button>
|
||||
<button matButton="filled" (click)="onFractalTypeChange(1)">{{ 'FRACTAL3D.MANDELBOX' | translate }}</button>
|
||||
<button matButton="filled" (click)="onFractalTypeChange(2)">{{ 'FRACTAL3D.JULIA' | translate }}</button>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<div class="algo-info">
|
||||
<h3>{{ algorithmInformation.title | translate }}</h3>
|
||||
<div class="mb-4 py-3 px-4 border border-[#ddd] rounded-lg">
|
||||
<h3 class="m-0 mb-2">{{ algorithmInformation.title | translate }}</h3>
|
||||
|
||||
@if(algorithmInformation.entries && algorithmInformation.entries.length > 0){
|
||||
@for (algo of algorithmInformation.entries; track algo)
|
||||
{
|
||||
<p>
|
||||
<p class="my-2">
|
||||
<strong>
|
||||
@if(algo.translateName){
|
||||
{{ algo.name | translate}}
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
@if (algorithmInformation.disclaimer !== '')
|
||||
{
|
||||
<p>
|
||||
<p class="my-2">
|
||||
<strong>{{ 'ALGORITHM.NOTE' | translate}}</strong> {{ algorithmInformation.disclaimer | translate}}
|
||||
</p>
|
||||
@if (algorithmInformation.disclaimerListEntry && algorithmInformation.disclaimerListEntry.length > 0)
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<mat-card class="algo-container">
|
||||
<mat-card class="w-full max-w-[1920px] p-5">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'LABYRINTH.TITLE' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<app-information [algorithmInformation]="algoInformation"/>
|
||||
<div class="controls-container">
|
||||
<div class="controls-panel">
|
||||
<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 matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
|
||||
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
|
||||
</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(false)">{{ 'LABYRINTH.KRUSKAL' | translate }}</button>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span><span class="legend-color start"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
|
||||
<span><span class="legend-color end"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
|
||||
<span><span class="legend-color wall"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
|
||||
<span><span class="legend-color visited"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
||||
<span><span class="legend-color path"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
||||
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
|
||||
<span><span class="legend-swatch bg-green-600"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
|
||||
<span><span class="legend-swatch bg-red-600"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
|
||||
<span><span class="legend-swatch bg-black"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
|
||||
<span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
||||
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
||||
</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.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
<mat-card class="algo-container">
|
||||
<mat-card class="w-full max-w-[1920px] p-5">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'PATHFINDING.TITLE' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<app-information [algorithmInformation]="algoInformation"/>
|
||||
|
||||
<div class="controls-container">
|
||||
<div class="controls-panel">
|
||||
<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 matButton="filled" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
|
||||
<button matButton="filled" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
|
||||
</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: '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: false, scenario: 'normal'})">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button>
|
||||
</div>
|
||||
|
||||
<div class="controls-panel">
|
||||
<mat-button-toggle-group [(ngModel)]="selectedNodeType" aria-label="Node Type Selection">
|
||||
<div class="flex gap-4 mb-4 items-center flex-wrap mt-2.5 text-[0.9em]">
|
||||
<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.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.None">{{ 'PATHFINDING.CLEAR_NODE' | translate }}</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
<div class="controls-panel">
|
||||
<div class="input-container">
|
||||
<mat-form-field appearance="outline" class="input-field">
|
||||
<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
|
||||
@@ -38,7 +38,7 @@
|
||||
(ngModelChange)="genericGridComponent.gridRows = gridRows; genericGridComponent.applyGridSize()"
|
||||
/> </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>
|
||||
<input
|
||||
matInput
|
||||
@@ -51,14 +51,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span><span class="legend-color start"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
|
||||
<span><span class="legend-color end"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
|
||||
<span><span class="legend-color wall"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
|
||||
<span><span class="legend-color visited"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
||||
<span><span class="legend-color path"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
||||
<div class="flex flex-wrap gap-4 items-center text-[0.9em]">
|
||||
<span><span class="legend-swatch bg-green-600"></span> {{ 'PATHFINDING.START_NODE' | translate }}</span>
|
||||
<span><span class="legend-swatch bg-red-600"></span> {{ 'PATHFINDING.END_NODE' | translate }}</span>
|
||||
<span><span class="legend-swatch bg-black"></span> {{ 'PATHFINDING.WALL' | translate }}</span>
|
||||
<span><span class="legend-swatch bg-sky-300"></span> {{ 'PATHFINDING.VISITED' | translate }}</span>
|
||||
<span><span class="legend-swatch bg-[gold]"></span> {{ 'PATHFINDING.PATH' | translate }}</span>
|
||||
</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.EXECUTION_TIME' | translate }}: {{ executionTime | number:'1.2-2' }} ms</p>
|
||||
</div>
|
||||
|
||||
134
src/app/pages/algorithms/pendulum/pendulum-glsl.shader.ts
Normal file
134
src/app/pages/algorithms/pendulum/pendulum-glsl.shader.ts
Normal 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);
|
||||
}
|
||||
`;
|
||||
@@ -1,45 +1,45 @@
|
||||
<mat-card class="algo-container">
|
||||
<mat-card class="w-full max-w-[1920px] p-5">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ 'PENDULUM.TITLE' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<app-information [algorithmInformation]="algoInformation"/>
|
||||
<div class="controls-container">
|
||||
<div class="sliders-grid">
|
||||
<div class="slider-item">
|
||||
<p>{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p>
|
||||
<ngx-slider [(value)]="simParams.trailDecay" [options]="trailDecayOptions" ></ngx-slider>
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="grid grid-cols-2 tablet:grid-cols-1 tablet:gap-4 mb-6">
|
||||
<div class="flex items-center gap-4 mr-4">
|
||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.TRAIL_DECAY_TIME' | translate }}</p>
|
||||
<ngx-slider class="grow" [(value)]="simParams.trailDecay" [options]="trailDecayOptions" ></ngx-slider>
|
||||
</div>
|
||||
<div class="slider-item">
|
||||
<p>{{ 'PENDULUM.ATTRACTION' | translate }}</p>
|
||||
<ngx-slider [(value)]="simParams.g" [options]="gravityOptions" ></ngx-slider>
|
||||
<div class="flex items-center gap-4 mr-4">
|
||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.ATTRACTION' | translate }}</p>
|
||||
<ngx-slider class="grow" [(value)]="simParams.g" [options]="gravityOptions" ></ngx-slider>
|
||||
</div>
|
||||
|
||||
<div class="slider-item">
|
||||
<p>{{ 'PENDULUM.L1_LENGTH' | translate }}</p>
|
||||
<ngx-slider [(value)]="simParams.l1" [options]="lengthOptions" ></ngx-slider>
|
||||
<div class="flex items-center gap-4 mr-4">
|
||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L1_LENGTH' | translate }}</p>
|
||||
<ngx-slider class="grow" [(value)]="simParams.l1" [options]="lengthOptions" ></ngx-slider>
|
||||
</div>
|
||||
<div class="slider-item">
|
||||
<p>{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
|
||||
<ngx-slider [(value)]="simParams.l2" [options]="lengthOptions" ></ngx-slider>
|
||||
<div class="flex items-center gap-4 mr-4">
|
||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.L2_LENGTH' | translate }}</p>
|
||||
<ngx-slider class="grow" [(value)]="simParams.l2" [options]="lengthOptions" ></ngx-slider>
|
||||
</div>
|
||||
|
||||
<div class="slider-item">
|
||||
<p>{{ 'PENDULUM.M1_MASS' | translate }}</p>
|
||||
<ngx-slider [(value)]="simParams.m1" [options]="massOptions" ></ngx-slider>
|
||||
<div class="flex items-center gap-4 mr-4">
|
||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M1_MASS' | translate }}</p>
|
||||
<ngx-slider class="grow" [(value)]="simParams.m1" [options]="massOptions" ></ngx-slider>
|
||||
</div>
|
||||
<div class="slider-item">
|
||||
<p>{{ 'PENDULUM.M2_MASS' | translate }}</p>
|
||||
<ngx-slider [(value)]="simParams.m2" [options]="massOptions" ></ngx-slider>
|
||||
<div class="flex items-center gap-4 mr-4">
|
||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.M2_MASS' | translate }}</p>
|
||||
<ngx-slider class="grow" [(value)]="simParams.m2" [options]="massOptions" ></ngx-slider>
|
||||
</div>
|
||||
|
||||
<div class="slider-item full-width">
|
||||
<p>{{ 'PENDULUM.DAMPING' | translate }}</p>
|
||||
<ngx-slider [(value)]="simParams.damping" [options]="dampingOptions" ></ngx-slider>
|
||||
<div class="flex items-center gap-4 mr-4 col-span-full">
|
||||
<p class="w-[100px] shrink-0 m-0 text-sm">{{ 'PENDULUM.DAMPING' | translate }}</p>
|
||||
<ngx-slider class="grow" [(value)]="simParams.damping" [options]="dampingOptions" ></ngx-slider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-container">
|
||||
<div class="flex flex-wrap gap-3 mb-4">
|
||||
<button mat-raised-button color="primary" (click)="pushPendulum(true)">
|
||||
{{ 'PENDULUM.POKE_M1' | translate }}
|
||||
</button>
|
||||
@@ -51,11 +51,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="legend" style="margin-top: 10px">
|
||||
<span><span class="legend-color L1"></span> L1</span>
|
||||
<span><span class="legend-color L2"></span> L2</span>
|
||||
<span><span class="legend-color M1"></span> M1</span>
|
||||
<span><span class="legend-color M2"></span> M2</span>
|
||||
<div class="flex flex-wrap gap-4 items-center text-[0.9em] mt-2.5">
|
||||
<span><span class="legend-swatch bg-yellow-400"></span> L1</span>
|
||||
<span><span class="legend-swatch bg-fuchsia-500"></span> L2</span>
|
||||
<span><span class="legend-swatch bg-red-600"></span> M1</span>
|
||||
<span><span class="legend-swatch bg-green-600"></span> M2</span>
|
||||
</div>
|
||||
</div>
|
||||
<app-babylon-canvas
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {BabylonCanvas, RenderConfig, SceneEventData} from '../../../shared/components/render-canvas/babylon-canvas.component';
|
||||
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 {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';
|
||||
@@ -11,6 +10,9 @@ import {MatButton} from '@angular/material/button';
|
||||
import {Information} from '../information/information';
|
||||
import {AlgorithmInformation} from '../information/information.models';
|
||||
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({
|
||||
selector: 'app-pendulum',
|
||||
@@ -31,7 +33,6 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
||||
})
|
||||
class PendulumComponent {
|
||||
|
||||
// --- CONFIGURATION ---
|
||||
algoInformation: AlgorithmInformation = {
|
||||
title: 'PENDULUM.EXPLANATION.TITLE',
|
||||
entries: [
|
||||
@@ -46,15 +47,9 @@ class PendulumComponent {
|
||||
disclaimerListEntry: ['PENDULUM.EXPLANATION.DISCLAIMER_1', 'PENDULUM.EXPLANATION.DISCLAIMER_2', 'PENDULUM.EXPLANATION.DISCLAIMER_3', 'PENDULUM.EXPLANATION.DISCLAIMER_4']
|
||||
};
|
||||
|
||||
|
||||
renderConfig: RenderConfig = {
|
||||
mode: '2D',
|
||||
initialViewSize: 2,
|
||||
shaderLanguage: ShaderLanguage.WGSL,
|
||||
vertexShader: PENDULUM_VERTEX_SHADER_WGSL,
|
||||
fragmentShader: PENDULUM_FRAGMENT_SHADER_WGSL,
|
||||
uniformNames: [],
|
||||
uniformBufferNames: []
|
||||
initialViewSize: 2
|
||||
};
|
||||
|
||||
trailDecayOptions: Options = {
|
||||
@@ -107,7 +102,6 @@ class PendulumComponent {
|
||||
hidePointerLabels: false
|
||||
};
|
||||
|
||||
// Central management of physics parameters
|
||||
readonly simParams = {
|
||||
time: 0,
|
||||
dt: 0.015,
|
||||
@@ -123,6 +117,7 @@ class PendulumComponent {
|
||||
};
|
||||
|
||||
private currentSceneData: SceneEventData | null = null;
|
||||
private strategy: PendulumSimulationStrategy | null = null;
|
||||
|
||||
onSceneReady(event: SceneEventData) {
|
||||
this.currentSceneData = event;
|
||||
@@ -133,80 +128,31 @@ class PendulumComponent {
|
||||
if (!this.currentSceneData) {
|
||||
return;
|
||||
}
|
||||
const {engine, scene} = this.currentSceneData;
|
||||
engine.resize();
|
||||
|
||||
const width = engine.getRenderWidth();
|
||||
const height = engine.getRenderHeight();
|
||||
const totalPixels = width * height;
|
||||
const {engine, scene, gpuTier} = this.currentSceneData;
|
||||
|
||||
// --- 1. BUFFERS ---
|
||||
const pixelBuffer = new StorageBuffer(engine, totalPixels * 4);
|
||||
|
||||
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);
|
||||
if (this.strategy) {
|
||||
this.strategy.dispose();
|
||||
}
|
||||
|
||||
//remove old observables if available
|
||||
scene.onBeforeRenderObservable.clear();
|
||||
// --- 4. RENDER LOOP ---
|
||||
scene.onBeforeRenderObservable.add(() => {
|
||||
this.strategy = gpuTier === 'webgpu'
|
||||
? new PendulumGpuStrategy()
|
||||
: new PendulumCpuStrategy();
|
||||
|
||||
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;
|
||||
|
||||
const currentWidth = engine.getRenderWidth();
|
||||
const currentHeight = engine.getRenderHeight();
|
||||
|
||||
// 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
|
||||
if (this.strategy) {
|
||||
this.strategy.updateParams({...this.simParams});
|
||||
}
|
||||
|
||||
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) {
|
||||
if (m1)
|
||||
{
|
||||
if (m1) {
|
||||
this.simParams.impulseM1 = IMPULSE_M1;
|
||||
return;
|
||||
}
|
||||
@@ -235,6 +180,4 @@ class PendulumComponent {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default PendulumComponent
|
||||
export default PendulumComponent;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export class SortingService {
|
||||
|
||||
let start = -1;
|
||||
let end = array.length-1;
|
||||
let changed = false;
|
||||
let changed: boolean;
|
||||
do {
|
||||
changed = false;
|
||||
start += 1;
|
||||
|
||||
@@ -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-title>{{ 'SORTING.TITLE' | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<app-information [algorithmInformation]="algoInformation"/>
|
||||
<div class="controls-panel">
|
||||
<mat-form-field appearance="fill">
|
||||
<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 class="w-[clamp(150px,20vw,200px)]" appearance="fill">
|
||||
<mat-label>{{ 'SORTING.ALGORITHM' | translate }}</mat-label>
|
||||
<mat-select [(ngModel)]="selectedAlgorithm">
|
||||
@for (algo of algoInformation.entries; track algo.name) {
|
||||
@@ -14,7 +14,7 @@
|
||||
</mat-select>
|
||||
</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>
|
||||
<input
|
||||
matInput
|
||||
@@ -27,7 +27,7 @@
|
||||
/>
|
||||
</mat-form-field>
|
||||
</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()">
|
||||
<mat-icon>play_arrow</mat-icon> {{ 'SORTING.START' | translate }}
|
||||
</button>
|
||||
@@ -43,17 +43,19 @@
|
||||
</button>
|
||||
</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>
|
||||
</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) {
|
||||
<div
|
||||
class="bar"
|
||||
class="grow w-2.5 min-w-px transition-all duration-[50ms]"
|
||||
[style.height.px]="item.value * 3"
|
||||
[class.unsorted]="item.state === 'unsorted'"
|
||||
[class.comparing]="item.state === 'comparing'"
|
||||
[class.sorted]="item.state === 'sorted'"
|
||||
[ngClass]="{
|
||||
'bg-[#424242]': item.state === 'unsorted',
|
||||
'bg-[#ffeb3b]': item.state === 'comparing',
|
||||
'bg-[#4caf50]': item.state === 'sorted'
|
||||
}"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<section class="imprint">
|
||||
<mat-card class="imprint-card">
|
||||
<h2 class="imprint-title">{{ 'IMPRINT.TITLE' | translate }}</h2>
|
||||
<section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
|
||||
<mat-card class="p-fluid-md">
|
||||
<h2 class="m-0 mb-4 text-[clamp(1rem,3vw,1.2rem)] font-semibold">{{ 'IMPRINT.TITLE' | translate }}</h2>
|
||||
|
||||
<div class="imprint-section">
|
||||
<p class="imprint-label">{{ 'IMPRINT.PARAGRAPH' | translate }}</p>
|
||||
<div class="grid gap-1 mb-4">
|
||||
<p class="text-xs tracking-[0.04em] uppercase opacity-70 m-0">{{ 'IMPRINT.PARAGRAPH' | translate }}</p>
|
||||
|
||||
<p>
|
||||
Andreas Dahm<br />
|
||||
@@ -13,8 +13,8 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="imprint-section">
|
||||
<p class="imprint-label">{{ 'IMPRINT.CONTACT' | translate }}</p>
|
||||
<div class="grid gap-1 mb-4">
|
||||
<p class="text-xs tracking-[0.04em] uppercase opacity-70 m-0">{{ 'IMPRINT.CONTACT' | translate }}</p>
|
||||
<p>
|
||||
E-Mail:
|
||||
<a href="mailto:andreas.dahm@gmail.com">
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
<h2 mat-dialog-title>{{ project.title | translate }}</h2>
|
||||
<mat-dialog-content #dialogContent>
|
||||
<div class="project-dialog-layout">
|
||||
<div class="project-info">
|
||||
<p class="introduction">{{ project.introduction | translate }}</p>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<p class="text-lg leading-relaxed opacity-90 mb-4">{{ project.introduction | translate }}</p>
|
||||
|
||||
<div class="features-list">
|
||||
<ul>
|
||||
<div class="mb-6">
|
||||
<ul class="pl-5">
|
||||
@for(bullet of project.bulletPoints; track bullet) {
|
||||
<li>{{ bullet | translate }}</li>
|
||||
<li class="mb-2">{{ bullet | translate }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="insight-grid">
|
||||
<div class="insight-card technical">
|
||||
<div class="insight-header">
|
||||
<mat-icon>settings_suggest</mat-icon>
|
||||
<h3>{{ 'PROJECTS.SECTION.TECHNICAL' | translate }}</h3>
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
|
||||
<div class="p-5 rounded-xl bg-black/[.03] border border-black/[.05] dark:bg-white/[.05] dark:border-white/10">
|
||||
<div class="flex items-center gap-3 mb-3 text-link">
|
||||
<mat-icon class="!text-[24px] !w-6 !h-6">settings_suggest</mat-icon>
|
||||
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.TECHNICAL' | translate }}</h3>
|
||||
</div>
|
||||
<ul>
|
||||
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||
@for(challenge of project.challenges; track challenge) {
|
||||
<li>{{ challenge | translate }}</li>
|
||||
<li class="mb-1">{{ challenge | translate }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="insight-card softskills">
|
||||
<div class="insight-header">
|
||||
<mat-icon>psychology</mat-icon>
|
||||
<h3>{{ 'PROJECTS.SECTION.LEARNINGS' | translate }}</h3>
|
||||
<div class="p-5 rounded-xl bg-black/[.03] border border-black/[.05] dark:bg-white/[.05] dark:border-white/10">
|
||||
<div class="flex items-center gap-3 mb-3 text-link">
|
||||
<mat-icon class="!text-[24px] !w-6 !h-6">psychology</mat-icon>
|
||||
<h3 class="m-0 text-sm uppercase tracking-wider font-semibold">{{ 'PROJECTS.SECTION.LEARNINGS' | translate }}</h3>
|
||||
</div>
|
||||
<ul>
|
||||
<ul class="m-0 pl-5 text-[0.95rem] leading-relaxed opacity-85">
|
||||
@for(learning of project.learnings; track learning) {
|
||||
<li>{{ learning | translate }}</li>
|
||||
<li class="mb-1">{{ learning | translate }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -41,14 +41,14 @@
|
||||
|
||||
@if (project.images.length > 0)
|
||||
{
|
||||
<div class="media-section">
|
||||
<swiper-container class="my-swiper" [attr.slides-per-view]="1" [attr.space-between]="12" [attr.navigation]="true"
|
||||
<div class="my-4 rounded-xl overflow-hidden bg-black">
|
||||
<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%;">
|
||||
@for (img of project.images; track img) {
|
||||
<swiper-slide>
|
||||
<img class="slide-img" [src]="img.url" [alt]="project.title | translate" />
|
||||
<swiper-slide class="rounded-xl overflow-hidden flex flex-col bg-[#222]">
|
||||
<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) {
|
||||
<div class="slide-source">
|
||||
<div class="text-xs text-[#aaa] bg-[#2a2a2a] p-2 text-right border-t border-[#444]">
|
||||
{{ img.source }}
|
||||
</div>
|
||||
}
|
||||
@@ -58,8 +58,8 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="footer-details">
|
||||
<div class="tech-stack">
|
||||
<div class="flex flex-col gap-4 pt-4 border-t border-black/10 dark:border-white/10">
|
||||
<div class="flex flex-wrap">
|
||||
<mat-chip-set aria-label="Technologies">
|
||||
@for(tech of project.technologies; track tech) {
|
||||
<mat-chip>{{tech}}</mat-chip>
|
||||
@@ -67,7 +67,7 @@
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
|
||||
<div class="link-section">
|
||||
<div class="flex flex-wrap gap-fluid-sm mt-6">
|
||||
@for(link of project.links; track link)
|
||||
{
|
||||
<a mat-button href="{{link.url}}" target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
<mat-card class="project-card featured">
|
||||
<mat-card-header>
|
||||
<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 class="pb-4">
|
||||
<mat-card-title>{{ project.title | translate }}</mat-card-title>
|
||||
<mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
@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 {
|
||||
<div class="icon-container">
|
||||
<mat-icon class="fallback-icon">{{ project.icon }}</mat-icon>
|
||||
<div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]">
|
||||
<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>
|
||||
}
|
||||
<mat-card-content>
|
||||
<mat-card-content class="grow pt-4 pb-4">
|
||||
<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) {
|
||||
<mat-chip>{{tech}}</mat-chip>
|
||||
}
|
||||
</mat-chip-set>
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<mat-card-actions class="mt-auto">
|
||||
<button mat-button (click)="openProjectDialog(project)">{{ 'PROJECTS.READ_MORE' | translate }}</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@for (project of otherProjects(); track project) {
|
||||
<mat-card class="project-card">
|
||||
<mat-card-header>
|
||||
<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 class="pb-4">
|
||||
<mat-card-title>{{ project.title | translate }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
@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 {
|
||||
<div class="icon-container">
|
||||
<mat-icon class="fallback-icon">{{ project.icon }}</mat-icon>
|
||||
<div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]">
|
||||
<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>
|
||||
}
|
||||
<mat-card-content>
|
||||
<mat-card-content class="grow pt-4 pb-4">
|
||||
<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) {
|
||||
<mat-chip>{{tech}}</mat-chip>
|
||||
}
|
||||
</mat-chip-set>
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<mat-card-actions class="mt-auto">
|
||||
<button mat-button (click)="openProjectDialog(project)">{{ 'PROJECTS.READ_MORE' | translate }}</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
|
||||
55
src/app/service/gpu-capability.service.ts
Normal file
55
src/app/service/gpu-capability.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
<div class="canvas-container">
|
||||
<canvas #gridCanvas></canvas>
|
||||
<div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
|
||||
<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>
|
||||
|
||||
@@ -7,9 +7,3 @@
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<div class="canvas-container">
|
||||
<canvas #renderCanvas></canvas>
|
||||
<div class="flex justify-center items-center w-full max-w-[1000px] mx-auto">
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, NgZone, OnDestroy, Output, ViewChild} from '@angular/core';
|
||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
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 {
|
||||
mode: '2D' | '3D';
|
||||
@@ -17,7 +18,8 @@ export type RenderCallback = (material: ShaderMaterial, camera: Camera, canvas:
|
||||
|
||||
export interface SceneEventData {
|
||||
scene: Scene;
|
||||
engine: WebGPUEngine;
|
||||
engine: WebGPUEngine | Engine;
|
||||
gpuTier: GpuTier;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -30,6 +32,7 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
||||
readonly ngZone = inject(NgZone);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly gpuCapability = inject(GpuCapabilityService);
|
||||
|
||||
@ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
@@ -38,11 +41,13 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
||||
|
||||
@Output() sceneReady = 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 shaderMaterial!: ShaderMaterial;
|
||||
private camera!: Camera;
|
||||
private gpuTier: GpuTier = 'none';
|
||||
|
||||
//Listener
|
||||
private readonly resizeHandler = () => this.handleResize();
|
||||
@@ -63,9 +68,44 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
||||
|
||||
private async initBabylon(): Promise<void> {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
const tier = await this.gpuCapability.detect();
|
||||
this.gpuTier = tier;
|
||||
|
||||
if (tier === 'webgpu') {
|
||||
await this.initWebGpuEngine(canvas);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
await tmpEngine.initAsync().then(() => {
|
||||
|
||||
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);
|
||||
@@ -73,16 +113,15 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
||||
this.createFullScreenRect();
|
||||
this.sceneReady.emit({
|
||||
scene: this.scene,
|
||||
engine: this.engine
|
||||
engine: this.engine,
|
||||
gpuTier: this.gpuTier
|
||||
});
|
||||
this.addRenderLoop(canvas);
|
||||
}
|
||||
|
||||
})
|
||||
.catch(() => {
|
||||
const message = this.translate.instant('WEBGPU.NOT_SUPPORTED');
|
||||
this.snackBar.open(message, 'OK', { duration: 8000, horizontalPosition: "center", verticalPosition: "top" });
|
||||
this.engine = null!;
|
||||
});
|
||||
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) {
|
||||
@@ -198,7 +237,8 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
||||
|
||||
this.sceneResized?.emit({
|
||||
scene: this.scene,
|
||||
engine: this.engine
|
||||
engine: this.engine,
|
||||
gpuTier: this.gpuTier
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,11 @@
|
||||
"EDUCATION": "Ausbildung"
|
||||
},
|
||||
"SKILLS": {
|
||||
"JAVA": "Java 8/21+",
|
||||
"SPRING": "Spring Boot 2/3",
|
||||
"ANGULAR": "Angular 19+",
|
||||
"JAVA": "Java",
|
||||
"SPRING": "Spring Boot",
|
||||
"ANGULAR": "Angular",
|
||||
"VUE": "Vue.js",
|
||||
"REACT": "React",
|
||||
"DOCKER": "Docker",
|
||||
"UNITY": "Unity",
|
||||
"PYTHON": "Python",
|
||||
@@ -63,8 +65,13 @@
|
||||
"XP": {
|
||||
"COMPANY9": {
|
||||
"COMPANY": "ColorDigital GmbH",
|
||||
"ROLE": "Senior Software Engineer",
|
||||
"TIME": "März 2026 – heute"
|
||||
"ROLE": "Senior Software Engineer - DMIxOS Team",
|
||||
"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": {
|
||||
"COMPANY": "Teraport GmbH",
|
||||
@@ -203,7 +210,7 @@
|
||||
},
|
||||
"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",
|
||||
"HIGHLIGHTS": {
|
||||
"P1": "Self-Hosting verschiedener Dienste mit Docker.",
|
||||
@@ -266,7 +273,7 @@
|
||||
"BULLET_1": "Entwicklung mit Angular 19+ und Material Design.",
|
||||
"BULLET_2": "Implementierung performanter Visualisierungen (WebGPU, Shader, Canvas).",
|
||||
"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_2": "Architektur einer skalierbaren und wartbaren Frontend-Struktur für diverse Sub-Projekte.",
|
||||
"LEARNING_1": "Effektives UI/UX-Design für komplexe datengesteuerte Visualisierungen.",
|
||||
@@ -303,7 +310,7 @@
|
||||
"TITLE": "Rapid Prototyping & Game Jams",
|
||||
"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.",
|
||||
"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_3": "Effektives Zeitmanagement und Scope-Kontrolle.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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_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.",
|
||||
@@ -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.",
|
||||
"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_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_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": {
|
||||
"NOT_SUPPORTED": "WebGPU konnte nicht gestartet werden. Bitte prüfe, ob dein Browser WebGPU unterstützt."
|
||||
"GPU": {
|
||||
"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": {
|
||||
"TITLE": "Algorithmen",
|
||||
@@ -527,6 +535,10 @@
|
||||
"TITLE": "Labyrinth-Erzeugung",
|
||||
"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": {
|
||||
"TITLE": "Fraktale",
|
||||
"DESCRIPTION": "Visualisierung von komplexe, geometrische Mustern, die sich selbst in immer kleineren Maßstäben ähneln (Selbstähnlichkeit)."
|
||||
@@ -546,5 +558,35 @@
|
||||
"NOTE": "HINWEIS",
|
||||
"GRID_HEIGHT": "Höhe",
|
||||
"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!)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,11 @@
|
||||
"EDUCATION": "Education"
|
||||
},
|
||||
"SKILLS": {
|
||||
"JAVA": "Java 8/21+",
|
||||
"SPRING": "Spring Boot 2/3",
|
||||
"ANGULAR": "Angular 19+",
|
||||
"JAVA": "Java",
|
||||
"SPRING": "Spring Boot",
|
||||
"ANGULAR": "Angular",
|
||||
"VUE": "Vue.js",
|
||||
"REACT": "React",
|
||||
"DOCKER": "Docker",
|
||||
"UNITY": "Unity",
|
||||
"PYTHON": "Python",
|
||||
@@ -63,8 +65,13 @@
|
||||
"XP": {
|
||||
"COMPANY9": {
|
||||
"COMPANY": "ColorDigital GmbH",
|
||||
"ROLE": "Senior Software Engineer",
|
||||
"TIME": "Mar. 2026 – now"
|
||||
"ROLE": "Senior Software Engineer - DMIxOS Team",
|
||||
"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": {
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"WEBGPU": {
|
||||
"NOT_SUPPORTED": "WebGPU could not be started. Please check if your browser supports WebGPU."
|
||||
"GPU": {
|
||||
"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": {
|
||||
"TITLE": "Algorithms",
|
||||
@@ -526,6 +534,10 @@
|
||||
"TITLE": "Maze Generation",
|
||||
"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": {
|
||||
"TITLE": "Fractals",
|
||||
"DESCRIPTION": "Visualisation of complex geometric patterns that resemble each other on increasingly smaller scales (self-similarity)."
|
||||
@@ -545,5 +557,35 @@
|
||||
"NOTE": "Note",
|
||||
"GRID_HEIGHT": "Height",
|
||||
"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 |
@@ -11,6 +11,6 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
<app-root class="flex flex-col min-h-screen"></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
770
src/styles.scss
770
src/styles.scss
@@ -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.all-component-themes($light-theme);
|
||||
|
||||
|
||||
// Dark-Mode
|
||||
.dark {
|
||||
@include mat.all-component-colors($dark-theme);
|
||||
}
|
||||
|
||||
/* ---- Custom variables ---- */
|
||||
/* ---- Custom variables (bridge Material tokens to CSS vars for Tailwind) ---- */
|
||||
:root {
|
||||
--app-maxWidth: 1200px;
|
||||
--app-bg: #{mat.get-theme-color($light-theme, surface-container-low)};
|
||||
@@ -50,37 +48,7 @@ $dark-theme: mat.define-theme((color: (theme-type: dark, primary: mat.$cyan-pale
|
||||
--link-color-hover: #9ad2ff;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
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 */
|
||||
/* ---- Material component theme transitions ---- */
|
||||
.mat-toolbar,
|
||||
.mat-mdc-card,
|
||||
.mat-sidenav,
|
||||
@@ -97,37 +65,14 @@ body {
|
||||
fill 220ms ease;
|
||||
}
|
||||
|
||||
/* links */
|
||||
a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: var(--link-color-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* cards */
|
||||
/* ---- Material card overrides ---- */
|
||||
.mat-mdc-card {
|
||||
position: relative;
|
||||
border-radius: var(--card-radius) !important;
|
||||
background: var(--card-bg) !important;
|
||||
box-shadow: var(--card-shadow-outer);
|
||||
overflow: hidden;
|
||||
|
||||
border: none;
|
||||
|
||||
transition:
|
||||
box-shadow 200ms ease,
|
||||
transform 200ms ease;
|
||||
|
||||
&.algo-container {
|
||||
width: 100%;
|
||||
max-width: 1920px;
|
||||
padding: 20px;
|
||||
}
|
||||
transition: box-shadow 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.mat-mdc-card::before {
|
||||
@@ -136,22 +81,12 @@ a {
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--card-border-inset),
|
||||
inset 0 -1px 0 var(--card-border-inset-dark);
|
||||
}
|
||||
|
||||
.mat-mdc-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* accordion */
|
||||
/* expansion panels like cards */
|
||||
/* ---- Material accordion/expansion panel overrides ---- */
|
||||
.mat-accordion {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -161,31 +96,20 @@ a {
|
||||
border-radius: var(--card-radius) !important;
|
||||
background: var(--card-bg) !important;
|
||||
overflow: hidden;
|
||||
/* ok */
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
|
||||
.mat-accordion .mat-expansion-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--card-border-inset),
|
||||
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 {
|
||||
background: transparent !important;
|
||||
}
|
||||
@@ -194,6 +118,7 @@ a {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* ---- Material dialog overrides ---- */
|
||||
.image-dialog-panel .mat-mdc-dialog-surface {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
@@ -210,235 +135,7 @@ a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.link-row {
|
||||
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 ---- */
|
||||
/* ---- Material menu overrides ---- */
|
||||
.mat-mdc-menu-item .mdc-list-item__primary-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -477,360 +174,7 @@ app-root {
|
||||
border-color: rgba(255, 255, 255, .06);
|
||||
}
|
||||
|
||||
/* ---- About Page Sections ---- */
|
||||
.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));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ---- Swiper shadow DOM (::part cannot be Tailwind) ---- */
|
||||
.my-swiper::part(button-prev),
|
||||
.my-swiper::part(button-next) {
|
||||
width: 35px;
|
||||
@@ -852,101 +196,3 @@ img[mat-card-image] {
|
||||
.my-swiper::part(pagination) {
|
||||
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
67
src/tailwind.css
Normal 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
37
tailwind.config.js
Normal 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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user