From 7172894ebb474b8aeee71adb427492c9d42223bc Mon Sep 17 00:00:00 2001 From: djoser Date: Sun, 26 Jul 2026 19:06:17 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20WebGPU=20portfolio=20?= =?UTF-8?q?=E2=80=94=20generative-art=20site=20with=20DOM=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 9 + .gitignore | 19 + AGENTS.md | 328 ++++++++ Dockerfile | 22 + README.md | 44 + docker-compose.yml | 6 + index.html | 493 +++++++++++ nginx.default.conf | 30 + package-lock.json | 1090 +++++++++++++++++++++++++ package.json | 22 + public/SpaceMono-Regular.ttf | Bin 0 -> 98320 bytes src/app.ts | 147 ++++ src/data/projects/auv-simulation.ts | 18 + src/data/projects/evol-engine.ts | 20 + src/data/projects/improv-gfx.ts | 17 + src/data/projects/index.ts | 66 ++ src/data/projects/point-cloud.ts | 24 + src/data/projects/radiance-caching.ts | 21 + src/data/resume.ts | 131 +++ src/engine/renderer.ts | 104 +++ src/engine/shaders/fullscreen.wgsl | 104 +++ src/engine/text/slug-generator.ts | 352 ++++++++ src/engine/text/text-layout.ts | 177 ++++ src/engine/text/text-renderer.ts | 205 +++++ src/engine/text/text.wgsl | 159 ++++ src/main.ts | 16 + src/ui/input.ts | 14 + src/ui/overlay.ts | 254 ++++++ tsconfig.json | 18 + vite.config.ts | 6 + 30 files changed, 3916 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 nginx.default.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/SpaceMono-Regular.ttf create mode 100644 src/app.ts create mode 100644 src/data/projects/auv-simulation.ts create mode 100644 src/data/projects/evol-engine.ts create mode 100644 src/data/projects/improv-gfx.ts create mode 100644 src/data/projects/index.ts create mode 100644 src/data/projects/point-cloud.ts create mode 100644 src/data/projects/radiance-caching.ts create mode 100644 src/data/resume.ts create mode 100644 src/engine/renderer.ts create mode 100644 src/engine/shaders/fullscreen.wgsl create mode 100644 src/engine/text/slug-generator.ts create mode 100644 src/engine/text/text-layout.ts create mode 100644 src/engine/text/text-renderer.ts create mode 100644 src/engine/text/text.wgsl create mode 100644 src/main.ts create mode 100644 src/ui/input.ts create mode 100644 src/ui/overlay.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4c17717 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.git +.gitignore +*.md +!README.md +.DS_Store +.vscode +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8146ac0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules +dist +.vite +*.log + +# IDE / editor +.vscode +.idea + +# OpenCode agent scratch +.opencode/ + +# Throwaway test scripts +test-*.cjs +test-*.mjs +test-*.js + +# LaTeX resume (unrelated to the site) +resume.tex diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5f1b6ca --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,328 @@ +# AGENTS.md + +Conventions and operational notes for AI agents (and humans) working in this repo. +Read this before editing. Keep it updated when conventions change. + +## Project + +A portfolio website using a **hybrid architecture**: a WebGPU `` renders +the generative-art background, and a DOM overlay on top of it owns every UI +element (nav, titles, body text, button). The canvas is the only GPU surface; +all interactive UI is standard HTML/CSS. Pointer position is still captured at +the window level and fed to the shader as a uniform for background reactivity. + +### North-star goals (from the kickoff decisions) +- Tech: **Vite + TypeScript**, `@webgpu/types` for ambient types. +- Rendering: a **single fullscreen-triangle fragment shader** draws the procedural + generative-art background only. All UI (text, nav, button) is **DOM overlay**. +- Aesthetic: **abstract generative art** (domain-warped fbm flow fields, noise). +- Sections: **Hero / About / Projects / Contact**, driven by `scene` state in the + app loop. Scene changes update the DOM overlay content and shift the shader's + `palette`. +- Deliver style: **solid foundation first, extend later.** + +## Commands + +```bash +npm install +npm run dev # vite dev server, opens browser +npm run build # tsc --noEmit (typecheck) + vite build +npm run typecheck # tsc --noEmit — run before considering work done +npm run preview # serve the built dist/ +``` + +**Every task that changes code MUST end with `npm run typecheck` (and `npm run build` +if you want a full check).** Do not commit code that fails typecheck. + +## Stack / tooling notes + +- TypeScript `strict`, plus `noUnusedLocals` / `noUnusedParameters`. Remove unused + params/vars or prefix with `_` rather than leaving them. +- WGSL shaders live in `src/engine/shaders/*.wgsl` and are imported with Vite's + `?raw` suffix (`import src from './x.wgsl?raw'`). Keep all GPU code in `.wgsl` + files — do not inline large shader strings in `.ts`. +- `@webgpu/types` provides global `GPUDevice`, etc. Do not import them; they are + ambient via `types` in `tsconfig.json`. +- Target a modern WebGPU browser (recent Chrome/Edge/ChromeOS/Safari preview). + `index.html` ships a `#fallback` notice shown when `navigator.gpu` is missing. +- `opentype.js` is a runtime dep (font parsing for the Slug text generator); + `@types/opentype.js` is a dev dep. **The Slug text engine is dormant** — see + the "Text rendering" section. The deps are kept so the dormant files still + typecheck; they are not wired into the render loop. + +## Architecture + +``` +src/ +├── main.ts # boot: create App, fall back on no WebGPU +├── app.ts # rAF loop, input→uniforms, scene state, DOM overlay wiring +├── engine/ +│ ├── renderer.ts # device/context/pipeline, single procedural pass +│ ├── shaders/ +│ │ └── fullscreen.wgsl # procedural background only, one fragment shader +│ └── text/ # DORMANT Slug text engine (kept, not wired in) +│ ├── slug-generator.ts # CPU: opentype.js → curve/band texture data (Slug port) +│ ├── text-layout.ts # CPU: text string → instance buffer data per glyph +│ ├── text-renderer.ts # GPU: instanced quad pipeline + textures + bind groups +│ └── text.wgsl # WGSL Slug shader (ray-traces quadratic Béziers per frag) +├── ui/ +│ ├── input.ts # window-level pointer position capture (mouse uniform) +│ └── overlay.ts # DOM overlay: scene toggling, resume/project HTML builders +└── data/ + ├── resume.ts # ResumeData type + RESUME object (name, contact, work, edu, skills) + └── projects/ + ├── index.ts # ProjectPage type + PROJECTS registry (add new projects here) + ├── radiance-caching.ts + ├── point-cloud.ts + ├── evol-engine.ts + ├── auv-simulation.ts + └── improv-gfx.ts +``` + +### Layout: DOM-owned + +UI layout is owned by **CSS** in `index.html` (flexbox + viewport units). There is +no shared pixel-space layout contract between the DOM and the shader anymore: the +shader draws only the fullscreen background, so it needs only `resolution`, `time`, +`scene`, and `mouse` — no per-element bounds. + +When you add a new UI element: add it to the DOM overlay in `index.html`, style it +with CSS, and wire any click/hover in `overlay.ts`. You do not need to touch +`renderer.ts`, `fullscreen.wgsl`, or uniforms unless the background itself must +react to the new element. + +### Hybrid DOM-overlay principle + +The **DOM overlay** (`#overlay` in `index.html`, `z-index: 1` over the canvas) +owns all visible UI: the nav bar, scene title/subtitle/body, and the cycle button. +The canvas underneath draws the generative background only. The overlay is +`pointer-events: none` with interactive children set to `pointer-events: auto`, so +pointermove still reaches the window for the shader's mouse uniform while clicks +land on the right element. Do not add UI shapes inside the fragment shader — if +you need a new visual element, prefer a DOM element with CSS. + +### Data layer + +Content lives in `src/data/` as typed TypeScript objects. No runtime parsing, no +fetches — data is imported directly and rendered into HTML at first visit. + +**Resume** (`src/data/resume.ts`): +- Exports `ResumeData` interface and `RESUME` object. +- Fields: `name`, `contact[]`, `location`, `summary`, `work[]`, `education[]`, `skills[]`. +- `overlay.ts` has `buildResumeHTML(data)` that generates the full resume DOM from + the typed object. To update resume content, edit `resume.ts`. + +**Projects** (`src/data/projects/`): +- Each project is its own `.ts` file exporting a `ProjectPage`: + `{ id, name, tagline, date, tech[], content }` where `content` is free-form HTML. +- `index.ts` exports the `ProjectPage` type and the `PROJECTS` registry array. +- **To add a project:** create `src/data/projects/my-project.ts`, import it in + `index.ts`, add to the `PROJECTS` array. It auto-appears on the Projects page. +- **To remove a project:** remove its import and array entry from `index.ts`. +- Clicking a project card opens a detail view; a back button returns to the list. + Navigation is handled by event delegation on `#projects-section` in `overlay.ts`. +- The list resets on every nav entry (if you're viewing a detail and switch tabs, + coming back shows the list again). + +**To add a new data-driven section**, follow the Projects/Resume pattern: +1. Create `src/data/whatever.ts` with a typed interface + data export. +2. Add a `#whatever-section` div to `index.html` with the same CSS pattern + (opacity transition, `.visible` class with fade-in animation). +3. In `overlay.ts`: query the div, add a `whateverBuilt` flag, build the HTML + from data on first visit, toggle `.visible`/`.hidden` classes in `setScene()`. +4. Extend `colorBase`/`speedBase` arrays in `app.ts` and update cycle modulo. + +### Coordinate conventions + +- Layout space = **drawing-buffer pixels** (CSS pixels × `dpr`, capped at 2). +- Shader convention: origin **bottom-left**, **y up**. `app.ts` flips input y: + `mouse.y = res.y - clientY * dpr`. +- `Renderer` keeps `resolution` in actual device pixels. + +## WGSL / shader conventions + +- One oversized triangle, 3 hardcoded vertices in the vertex shader; no vertex buffers. +- Uniforms are a single `var u: Uniforms` at group 0, binding 0. +- Update `UNIFORM_FLOATS` in `renderer.ts` whenever you add a uniform field, and keep + the field order in TS `render(state)` identical to the WGSL struct declaration. +- Keep the shader readable: section it with `// ---------- name ----------` comments. + +## SDF reference helpers already in the shader + +- `sdRoundBox(p, halfExtent, radius)` — rounded rect (kept for future background motifs) +- `hash`, `noise`, `fbm` — value-noise fbm for the generative background +- `palette(s)` — hue shift keyed off `scene` + +To add more shapes, add `sd*` helpers next to the existing ones. + +## Currently rendered + +- **Background (shader):** domain-warped fbm flow field, palette indexed by `scene`, + vignette + grain. `scene` cycles 0→1→2→3→4 on each button click or nav click; each + scene shifts `palette`. `colorBase` and `speedBase` arrays in `app.ts` are indexed + by scene and must be extended when adding a new scene. +- **Nav bar (DOM):** top-center row of 5 ` + + + + + +
+

+

+

+
+ +
+
+ +
+
+

WebGPU not available

+

Open this page in a recent Chrome/Edge (or any WebGPU-enabled browser).

+
+
+ + + \ No newline at end of file diff --git a/nginx.default.conf b/nginx.default.conf new file mode 100644 index 0000000..2d33b2f --- /dev/null +++ b/nginx.default.conf @@ -0,0 +1,30 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Security headers + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Enable compression + gzip on; + gzip_types text/css application/javascript text/javascript application/wasm image/svg+xml; + gzip_min_length 512; + + # Immutable cache for hashed assets + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Everything else → index.html (SPA fallback) + location / { + try_files $uri $uri/ /index.html; + expires -1; + add_header Cache-Control "no-cache"; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..59006fd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1090 @@ +{ + "name": "webgpu-portfolio", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "webgpu-portfolio", + "version": "0.0.0", + "dependencies": { + "@playwright/test": "^1.61.1", + "opentype.js": "^2.0.0" + }, + "devDependencies": { + "@types/opentype.js": "^1.3.10", + "@webgpu/types": "^0.1.40", + "typescript": "^5.4.5", + "vite": "^5.2.11" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/opentype.js": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@types/opentype.js/-/opentype.js-1.3.10.tgz", + "integrity": "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/opentype.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-2.0.0.tgz", + "integrity": "sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==", + "license": "MIT", + "bin": { + "ot": "bin/ot" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.17", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz", + "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..01fb0a7 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "webgpu-portfolio", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/opentype.js": "^1.3.10", + "@webgpu/types": "^0.1.40", + "typescript": "^5.4.5", + "vite": "^5.2.11" + }, + "dependencies": { + "@playwright/test": "^1.61.1", + "opentype.js": "^2.0.0" + } +} diff --git a/public/SpaceMono-Regular.ttf b/public/SpaceMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8bf82cece1da361de59df7741ffd80b47df14e4e GIT binary patch literal 98320 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

KHt~wPsw7#EAVC}rkPG13+dGX&^A8K^H+B z6HIL;p!pu)lM$hfFtZ?Dh;pRGy%b87lE2SItKuaP2_{2K(NY9wHEH~3?6H4PKeDfA zsmb?cGKG|Ju)ZVG&jE>VV2*D+eujX9vcCkN+(7{8-c05p0jDF!UE$qk=bQ_BEa9PdGh_feMkpK0O5sdz_>iL%YAE|x% zx!_zQ{r>xl-h-g!r@chc2`i6C_hQUpti${{HQv2_pnbrE>D`yRhdp-h+P%uXYY#nK z+Ezr;qp8Q_!A8(Z(EPr8AA0y9dV|A=)FJBGE``$_T-d?hSt(ct3(%*0TCG;Q)m~=+ z>ZC9@4UgLG+7zg7Z5p|sVcG=$-=yz$b-D!o!j%7?+Riq%t?G>9_gp8}iE|srjuSi0 zOOrT>>$piAJB~||wjoKO>q1CMx)v5fiN*#S8)Hlr6BX*FDOB6~VX#(Iswz~$hP0_; zNbJiPAT$*wm1!>lqORKz0=5rm`@*Vypw52Jxz}$Ylrm|NlGr}i=lwkAdHp}Sdbite zl+-R~M9*PG(+l~rhlum?twb4 z+uCnDVfxi&_IFmaQjsu{k`$_Gc{l|)l{2jb z9OBI_0f!Z!GVqN#fnG@tB;tVCWm~hMdX3Y8Cyvh{E|{^Sog%DhMiZ}AE&vWAV}b|$ zxH5HDth2_fwySHu6uZmc-q6$KGl^cczY_c6ruOhv%G76jJFDw!rc*cB{zO+*p!(+2 zj4$xhjXq>Q?jl~dOMRdD*sgs8`~29-Q1==0K88GjJAeeJqu~3)D zsp7hdVPyTl#3-T%c300sC4K*P&qF1Be=D`<_VA9>qA#F3s87gyn(9>=#JTwqnZkI; zv;Xd=tV~Oz4V`!qEw3-QbsXI`+@71Tv0G=>P`}FkSe<=ue$Qzh-=m(^`3<~AzaDiI z#R6kqrWsS86k(DKQ}iqZTXNt&AaE!<7XC**~jp$4CUUxd>P6#WV~i3 z)xWq><9J-d53`TivX=7RV^`JS1!Y+HH_3=!&e;+=%Vl$cm0 z^0KR=(sCk#OJh5&=wr7NEBh_^L*g{^_Yx6uCFw+>Bw2(ZWQv$X!SqDxJ;14ABNg94 zRdEj#OQss(9-nD0}oG5K78Qh^is&4nGY>29$Pqkc;VRMLTH}x z`sv|*W8apD+oE#5v=2S$VvYY6a5Mdtk1S%o5u>bYl9iFIv<;+Shp}z1l z+kF1Y6lrjT*7L_~rcSTXGyciDkvUDHnW=2z+8PUCRkOCIo&1nx8C$o|E zyg7a7iFx}sbFaa>sbG=pa40( zeEA2*udPCKK6NZCs@Wg{2%?>i_eKD9q6YHH0Zl*x2WHs%bI29GUE8y}YV22m75&Ey zKPr-|8*AzBZ^?ZK=@N9$K|DT%Kiq%zHQC z$0J&?#7!b|R^a}xlMW3go!E$MCOJAd8jrOPbPSZ{H-!TWlFYxP{E z{FR=mczmiSHW`mk#$w5&{LTox>+uZ=)XC`7Kyoq~olFi)MTawqL?)9M7`y@!Y;I)3 zBIx!uW@D@{8(Cwwaf|W5_}BCE5pN~$bq3RG#WW#;aLF<$_~O(kJf8`TC`?P8&+n!O|98g9ARbZ5yltqh z4K&_FrmbzbrS9%Pb93PCx|ZTA&dNQ;EMAEBvd_B4J4lN}@+@tP(!Mf9cYBCdSeV%E zfCCJac7T6yz1J8&rw*Och(ELHX^sAKinm|^>AaA0#F*`6G($zsd5nfQg7nmF2s7#maZw^$@B<3R;1J%a`RQjj%KlgfmILk6Pl}P7a zcg9MEpLUHLuqMck1Jev)1DV=# zXW|4$QGQH8Jth|#N`;k%hB~2_s4$9UXOZ-KSu^S@TPwnR~ss+f-t?bSvga3SJyhvjJvmqyc|zIzEkIaGGGF37vWh|7+lyrD<=ebX$O62XWH5xGFN5O^h%#5;~Z zT=9$peATMYxVwL7pI+oodih@ZNOS4JzS^Ld=~GPgF!S9Hw**F|*cwG2kF%KOl|GKC z^AQWk^OZr()Op}zJTZQwo_EfjJALllynXt#eOfK5#oXE4*)BDrMsnwADP`_d%WyS< zI+@l|$5tc+*63FLp%|#dk3%-cM!1u1gkV8S#id<%t;SvT$-D0VVnW=!I)!TAr**+c zs4-%??W-flt#7Y@9#^cJI#XyHerG-d?{$r+P4$?9m*;PrFh>p_{-3^|O?y)lx6SMC z%Z!ewMcuyFNa<0IJh0!9tub={OOg9Ls(y|=2k;udQGBvKcwd#J&s`JJr_Jqcg zZ~z|UC?ff4kJ}=$1MJl&05~j^B8aH->rpG7WCSlU0$co6fv-Yv?PSPvU=fc^V@by$?xR&H z5*z=z5)G^)sS**bT<9RZn&RM|N1K)x)|tW4-J$yW(C!g)WGv~k>%!qW+m{@p#tY2* z;~q<&#ZQijyp|JLnrH!y>*#1geuI%nkY97ed7Bs=)9?ArnUR=!gniax?9#h;s3`)m z_u-!^sXMsB9qp6IIU@8*gr*MnYCyrn;Bht-3={XP#ze3!S2Ahu0gRV*O`WwtKanf( z!QR^L=GL}NHaM0w-o#+sA2VZ-aA#%BVDDg{X>((JtyP`s9d!CP#w<;kM?S^*M$tEh zC&9?XgpBV`W~ch0d71G|>hW#&*CM(ZYBRE%NyI()81fhLzBNT>`7MjCt}0#C_!R=vva|!4FwxR zP5xlp)>OPR-9ANwZ$S<3^$c-(-PLyyW9SNO#75Y0MU0-3IoDh@nuQ`xkLoGKjuC3P zR7ByCIk~9LtM6&rGOXt$;nJ7VX;`O6DUujH4VaHI85sEd4Ite7#XFk4zLvPq`$Xq@ zGDEBHuMMdO*IxP*)E}|Ac*EqOnlBVC6zWXnE*USZ{gFBR_>njZ3LSA)^cqI8vy@=w zE|n8ZT3uP&rXJOsKo8MY(*bgFBo+RaP4n z4SUh?0uvpl4rt;gcELp2#Wjl%)=J?p<#~N+mp*q5bs>${Df=wQ$uagI~^MITbtP2!z)|cg8rsZV=z=}Eljni zqVbgUX>Eb)p$&QCfXvTxcut`CaWX%JHCtIqYq=i5C_s@)Ng1hi3dl&Lj9MFZhkWA| z#olIyvh>gj>F8WF(>myB*iD-B-Y2`etNn+TxOE#Rn5FY;d(|0s-T#T=%N8xC_$pN( z`M)fq_{w-{?P2vIeYs{{MGz*RQk%asF+m2Dl*gx*JtxTyvclfGU{w9WFrT?V@)638 zK}#;dSy|@F=Ok-Pp3|~S7;xUd0~I|gcf9$A@hsmi|Lo1eXQz4YRi3lV)i294`CHmD zFY537obuwc+YeVuP} Vx}M;>micZi<%Qj3neQ3Ke*q`P36=l= literal 0 HcmV?d00001 diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..e0c9b21 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,147 @@ +import { Renderer, UiState } from './engine/renderer' +import { Input } from './ui/input' +import { DomOverlay } from './ui/overlay' + +const DPR_CAP = 2 + +interface SceneContent { + title: string + subtitle: string + body: string +} + +const SCENES: SceneContent[] = [ + { + title: 'Ahmed A. Mohamed', + subtitle: 'Creative Developer & Graphics Engineer', + body: 'Welcome to my personal website, there\'s a lot going on in my life that I will be sharing here randomly over time so take a look.' + }, + { + title: 'ABOUT', + subtitle: 'Who I am', + body: 'I build creative web experiences\nusing cutting-edge graphics technology.' + }, + { + title: 'PROJECTS', + subtitle: 'Things I have built', + body: 'A collection of work exploring\nWebGPU, shaders, and generative art.' + }, + { + title: 'CONTACT', + subtitle: 'Get in touch', + body: 'Reach me at hello@example.com\nor find me on GitHub.' + }, + { + title: 'RESUME', + subtitle: 'Ahmed Mohamed', + body: 'Graphics & Systems Engineer' + } +] + +export class App { + private canvas: HTMLCanvasElement + private renderer = new Renderer() + private input!: Input + private overlay!: DomOverlay + + private dpr = 1 + private res = new Float32Array(2) + private mouse = new Float32Array(2) + + private scene = 0 + private prevScene = -1 + private startTime = 0 + private rafId = 0 + private running = false + private stemMultipliers = new Float32Array(32) + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas + } + + async start(): Promise { + this.input = new Input() + this.overlay = new DomOverlay(SCENES, { + onNavClick: (s) => { this.scene = s }, + onButtonClick: () => { this.scene = (this.scene + 1) % 5 } + }) + this.overlay.setScene(this.scene) + this.resize() + const ok = await this.renderer.init(this.canvas) + if (!ok) return false + this.startTime = performance.now() + this.running = true + this.loop() + return true + } + + stop(): void { + this.running = false + cancelAnimationFrame(this.rafId) + } + + private resize(): void { + this.dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP) + const w = Math.floor(window.innerWidth * this.dpr) + const h = Math.floor(window.innerHeight * this.dpr) + if (this.canvas.width !== w || this.canvas.height !== h) { + this.canvas.width = w + this.canvas.height = h + this.res[0] = w + this.res[1] = h + if (this.renderer.ready) this.renderer.resize() + } + } + + private computeStemMultipliers(time: number): void { + const sm = this.stemMultipliers + // scene-based color identity and speed + const colorBase = [0.0, 0.6, 1.2, 1.8, 2.4] + const speedBase = [0.0, 0.2, 0.1, 0.0, 0.15] + const scene = this.scene + + // [0] = (camera speed, 0, color offset, twist/flash) + sm[0] = speedBase[scene] + sm[2] = colorBase[scene] * 0.2 + sm[3] = 0.3 + 0.3 * Math.sin(time * 0.5) + + // [1] = (color offset, 0, 0, 0) + sm[4] = colorBase[scene] * 0.4 + + // [2] = (size scale, 0, 0, 0) + sm[8] = 0.1// + 0.4 * Math.sin(time * 0.3 + scene * 3.0) + + // rest of the array stays zero + } + + private loop = (): void => { + if (!this.running) return + this.rafId = requestAnimationFrame(this.loop) + + const now = performance.now() + const time = (now - this.startTime) / 1000 + + this.resize() + + const mx = this.input.x * this.dpr + const my = this.res[1] - this.input.y * this.dpr + this.mouse[0] = mx + this.mouse[1] = my + + if (this.scene !== this.prevScene) { + this.prevScene = this.scene + this.overlay.setScene(this.scene) + } + + this.computeStemMultipliers(time) + + const state: UiState = { + resolution: this.res, + time, + scene: this.scene, + mouse: this.mouse, + stemMultipliers: this.stemMultipliers + } + this.renderer.render(state) + } +} \ No newline at end of file diff --git a/src/data/projects/auv-simulation.ts b/src/data/projects/auv-simulation.ts new file mode 100644 index 0000000..a0a913a --- /dev/null +++ b/src/data/projects/auv-simulation.ts @@ -0,0 +1,18 @@ +import { ProjectPage } from './index' + +const page: ProjectPage = { + id: 'auv-simulation', + name: 'AUV Simulation & Interfacing', + tagline: 'Full physics simulation + ROS + Qt ground control', + date: '2021', + tech: ['C++', 'ROS', 'Gazebo', 'Qt5'], + content: ` +

Full AUV physics simulation in Gazebo with ROS nodes using GStreamer to pipeline +media from AUV/simulation to an external monitoring interface via a Qt application +for the ground control team.

+ +

Designed the simulator, developed the Gazebo plugins and the C++ ROS nodes.

+`, +} + +export default page diff --git a/src/data/projects/evol-engine.ts b/src/data/projects/evol-engine.ts new file mode 100644 index 0000000..3e38208 --- /dev/null +++ b/src/data/projects/evol-engine.ts @@ -0,0 +1,20 @@ +import { ProjectPage } from './index' + +const page: ProjectPage = { + id: 'evol-engine', + name: 'Evol Game Engine', + tagline: 'Graduation Project — Vulkan-powered engine', + date: '07/2021', + tech: ['C', 'C++', 'Vulkan', 'OpenGL', 'ImGUI'], + content: ` +

Developed a high-performance, Vulkan-powered game engine with real-time physics +simulation, applicable to vehicle simulation and digital twin technology.

+ +

Built a modular scene editor for real-time rendering and physics-based simulation.

+ +

Implemented the Vulkan renderer for the engine core and an OpenGL PBR renderer +for the editor.

+`, +} + +export default page diff --git a/src/data/projects/improv-gfx.ts b/src/data/projects/improv-gfx.ts new file mode 100644 index 0000000..a566790 --- /dev/null +++ b/src/data/projects/improv-gfx.ts @@ -0,0 +1,17 @@ +import { ProjectPage } from './index' + +const page: ProjectPage = { + id: 'improv-gfx', + name: 'ImprovGFX', + tagline: 'From-scratch rasterizer with OpenCL GPU acceleration', + date: '2021', + tech: ['C++', 'OpenCL'], + content: ` +

A renderer/rasterizer built from scratch to render .obj models and .tga textures, +with OpenCL providing GPU acceleration.

+ +

Built a clean API for constructing scenes and shaders.

+`, +} + +export default page diff --git a/src/data/projects/index.ts b/src/data/projects/index.ts new file mode 100644 index 0000000..98c6ab8 --- /dev/null +++ b/src/data/projects/index.ts @@ -0,0 +1,66 @@ +// ---------- types ---------- + +export interface ProjectPage { + id: string + name: string + tagline: string + date: string + tech: string[] + content: string // free-form HTML +} + +export interface ProjectCategory { + id: string + label: string + description: string + projects: ProjectPage[] +} + +// ---------- project files ---------- + +import radianceCaching from './radiance-caching' +import pointCloud from './point-cloud' +import evolEngine from './evol-engine' +import auvSimulation from './auv-simulation' +import improvGfx from './improv-gfx' + +// ---------- flat list (for lookups) ---------- + +export const PROJECTS: ProjectPage[] = [ + radianceCaching, + pointCloud, + evolEngine, + auvSimulation, + improvGfx, +] + +// ---------- categories ---------- +// Add new categories here. Projects go in the `projects` array. +// Empty categories get a placeholder card on the grid. + +export const CATEGORIES: ProjectCategory[] = [ + { + id: 'programming', + label: 'Programming', + description: 'Graphics, engines, tools, and low-level systems.', + projects: [radianceCaching, pointCloud, evolEngine, improvGfx, auvSimulation], + }, + { + id: 'hardware', + label: 'Hardware', + description: 'Embedded, FPGA, PCB, and physical builds.', + projects: [], + }, + { + id: 'visuals', + label: 'Visuals', + description: 'Shaders, demoscene, generative art, and rendering experiments.', + projects: [], + }, + { + id: 'music', + label: 'Music', + description: 'Production, sound design, tools, and performances.', + projects: [], + }, +] diff --git a/src/data/projects/point-cloud.ts b/src/data/projects/point-cloud.ts new file mode 100644 index 0000000..cf47985 --- /dev/null +++ b/src/data/projects/point-cloud.ts @@ -0,0 +1,24 @@ +import { ProjectPage } from './index' + +const page: ProjectPage = { + id: 'point-cloud', + name: 'GPU-Driven Point Cloud Renderer', + tagline: 'TUM — 140M+ points at ~100 FPS', + date: '2024', + tech: ['C++', 'Vulkan', 'GLSL'], + content: ` +

Implemented a real-time GPU-driven renderer for large point clouds (140M+ points +at ~100 FPS) using a custom 5-stage compute shader pipeline in Vulkan, based on +Schütz et al. (2021, 2022).

+ +

Created a software rasterization pipeline using atomic uint64 depth-color packing +and compute shaders, enabling fine-grained depth testing without a hardware depth +buffer.

+ +

Implemented batch-based frustum culling, Morton-code spatial sorting, and +screen-coverage LOD selection via 10-bit quantized position precision. Used Vulkan +subgroup extensions for warp-level optimizations.

+`, +} + +export default page diff --git a/src/data/projects/radiance-caching.ts b/src/data/projects/radiance-caching.ts new file mode 100644 index 0000000..4e48c02 --- /dev/null +++ b/src/data/projects/radiance-caching.ts @@ -0,0 +1,21 @@ +import { ProjectPage } from './index' + +const page: ProjectPage = { + id: 'radiance-caching', + name: 'Radiance Caching for Real-Time Volumetric Rendering', + tagline: "Master's Thesis — TUM", + date: '12/2025 — 06/2026', + tech: ['C++', 'CUDA', 'Vulkan', 'OptiX'], + content: ` +

Researched and implemented a real-time radiance cache for volumetric path tracing +using 3D Gaussian Splatting and a fused MLP as the cache representation.

+ +

Developed an online differentiable optimization pipeline in CUDA that adapts the +Gaussian cache to dynamic lighting and transfer function changes during rendering.

+ +

Built a spatial-hash-based fused MLP for radiance caching on volumetric +heterogeneous media. Supervised by Prof. Westermann.

+`, +} + +export default page diff --git a/src/data/resume.ts b/src/data/resume.ts new file mode 100644 index 0000000..2129453 --- /dev/null +++ b/src/data/resume.ts @@ -0,0 +1,131 @@ +// ---------- resume data types ---------- + +export interface ResumeEntry { + role: string + company: string + location: string + date: string + bullets: string[] + thesis?: string // education only +} + +export interface SkillGroup { + label: string + skills: string[] +} + +export interface ResumeData { + name: string + contact: { label: string; href?: string }[] + location: string + summary: string + education: ResumeEntry[] + work: ResumeEntry[] + skills: SkillGroup[] +} + +// ---------- resume content ---------- + +export const RESUME: ResumeData = { + name: 'Ahmed Mohamed', + contact: [ + { label: '(+49) 15202103583' }, + { label: 'ed.mohamed@tum.de', href: 'mailto:ed.mohamed@tum.de' }, + { label: 'linkedin.com/in/inedited/', href: 'https://linkedin.com/in/inedited/' }, + { label: 'github.com/inedited', href: 'https://github.com/inedited' }, + ], + location: 'Gustav-Schwab-Str., 4, 81673 München, Germany', + summary: + 'Experienced software developer with a strong background in graphics programming, real-time rendering, XR development and AI engineering. Experienced in developing high-performance applications and specializing in low-level optimizations.', + education: [ + { + role: 'Master of Science in Informatics: Games Engineering', + company: 'Technical University of Munich (TUM)', + location: 'Munich, Germany', + date: '04/2024 \u2014 06/2026', + thesis: + 'Thesis: Real-time volume path tracing using neural radiance caching and 3DGS Radiance cache.', + bullets: [], + }, + { + role: 'Bachelor of Science in Software Systems and Computer Engineering', + company: 'Ain Shams University', + location: 'Cairo, Egypt', + date: '07/2016 \u2014 07/2021', + thesis: 'Thesis: 3D game engine in C.', + bullets: [], + }, + { + role: 'Dual Bachelor of Science in Software Systems and Computer Engineering', + company: 'University of East London', + location: 'London, United Kingdom', + date: '07/2018 \u2014 07/2021', + bullets: [], + }, + ], + work: [ + { + role: 'Software Developer (Working Student)', + company: 'Siemens AG', + location: 'Munich, Germany', + date: '04/2025 \u2014 Present', + bullets: [ + 'Led full cycle development across Physical AI, simulation, XR, and robotics integration projects within the Industrial Metaverse Lab.', + 'Worked on technical collaboration projects with NVIDIA, Meta, and Amazon AWS for Industrial AI applications.', + 'Built XR applications for Meta Quest, Meta RayBan, and Apple Vision Pro, implementing real time XR AI assisted solutions for industrial environments.', + 'Worked on robotics simulation and training using NVIDIA IsaacSim, developed ROS integrations, Teleoperation protocols, and deployed trained models to physical robots.', + 'Deployed and fine-tuned LLM models for industrial applications and internal tooling.', + ], + }, + { + role: 'Software Developer', + company: 'The Forge ConfettiFX', + location: 'California (Remote), USA', + date: '03/2022 \u2014 08/2023', + bullets: [ + 'Contributed to real-time rendering engine for Forza Motorsport 8 with Turn10 studio, optimizing rendering performance and rewriting shadow rendering system and asset pipelines.', + 'Improved The Forge rendering framework visibility buffer implementation, optimizing the graphics pipeline for real time rendering and testing across different consoles.', + 'Developed and optimized Linux ports for The Forge rendering framework and optimized the framework for Steam Deck.', + 'Redesigned the CI/CD testing pipeline for a Linux-based rendering framework, reducing testing time.', + ], + }, + { + role: 'Software Developer', + company: 'SciChart', + location: 'London (Remote), United Kingdom', + date: '01/2022 \u2014 01/2025', + bullets: [ + 'Developed and optimized the SciChart 3D graphics engine for high performance data visualization, simulation, telemetry, and real time analytics.', + 'Ported the 3D graphics engine and WPF Desktop application to Linux.', + 'Worked on the development of JavaScript/WebGL version of the 3D graphics engine on top of the C++ core engine.', + 'Implemented a Vulkan rendering backend, increasing performance on large scale datasets by about 20% across multiple platforms.', + ], + }, + { + role: 'Software Developer', + company: '412 Labs', + location: 'Cairo, Egypt', + date: '03/2021 \u2014 12/2021', + bullets: [ + 'Developed VR applications using Unity for different client projects for architectural visualization and internal designs.', + 'Taught a class of 20 students on VR development fundamentals using Unity.', + 'Built and deployed multiple full stack solutions for 412 Labs projects and clients.', + ], + }, + ], + + skills: [ + { + label: 'Languages', + skills: ['C', 'C++', 'C#', 'Python','Swift','TypeScript','Kotlin', 'GLSL', 'HLSL'], + }, + { + label: 'Graphics & HPC', + skills: ['Vulkan', 'OpenGL', 'DirectX', 'CUDA', 'WebGpu', 'Volume Rendering', 'Path Tracing'], + }, + { + label: 'Other', + skills: ['CI/CD', 'AI Agents', 'LLM', 'ROS', 'Unity', 'Unreal Engine', 'Git', 'Docker'], + }, + ], +} diff --git a/src/engine/renderer.ts b/src/engine/renderer.ts new file mode 100644 index 0000000..9bb451d --- /dev/null +++ b/src/engine/renderer.ts @@ -0,0 +1,104 @@ +import shaderSource from './shaders/fullscreen.wgsl?raw' + +const UNIFORM_FLOATS = 48 // res(2), time, scene, mouse(2), pad(2), date(4), sampleRate(4), stemMultipliers(32) +const UNIFORM_BYTES = UNIFORM_FLOATS * 4 + +export interface UiState { + resolution: Float32Array + time: number + scene: number + mouse: Float32Array + stemMultipliers: Float32Array +} + +export class Renderer { + private device!: GPUDevice + private context!: GPUCanvasContext + private format!: GPUTextureFormat + private pipeline!: GPURenderPipeline + private uniformBuffer!: GPUBuffer + private bindGroup!: GPUBindGroup + private uniformData = new Float32Array(UNIFORM_FLOATS) + private configured = false + + get ready(): boolean { + return this.configured + } + + async init(canvas: HTMLCanvasElement): Promise { + if (!navigator.gpu) return false + const adapter = await navigator.gpu.requestAdapter() + if (!adapter) return false + this.device = await adapter.requestDevice() + this.context = canvas.getContext('webgpu') as GPUCanvasContext + this.format = navigator.gpu.getPreferredCanvasFormat() + this.configure() + + const module = this.device.createShaderModule({ code: shaderSource }) + this.pipeline = this.device.createRenderPipeline({ + layout: 'auto', + vertex: { module, entryPoint: 'vs' }, + fragment: { module, entryPoint: 'fs', targets: [{ format: this.format }] }, + primitive: { topology: 'triangle-list' } + }) + + this.uniformBuffer = this.device.createBuffer({ + size: UNIFORM_BYTES, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }) + this.bindGroup = this.device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }] + }) + + this.device.lost.then((info) => { console.error('WebGPU device lost:', info); this.configured = false }) + this.device.onuncapturederror = (ev: GPUUncapturedErrorEvent) => { + console.error('WebGPU uncaptured error:', ev.error) + } + this.configured = true + return true + } + + configure(): void { + this.context.configure({ + device: this.device, + format: this.format, + alphaMode: 'opaque' + }) + } + + resize(): void { + this.configure() + } + + render(state: UiState): void { + if (!this.configured) return + const d = this.uniformData + d[0] = state.resolution[0] + d[1] = state.resolution[1] + d[2] = state.time + d[3] = state.scene + d[4] = state.mouse[0] + d[5] = state.mouse[1] + // d[6], d[7]: padding — left as zero + // d[8]-d[11]: date — left as zero + // d[12]-d[15]: sampleRate — left as zero + d.set(state.stemMultipliers, 16) + this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData) + + const encoder = this.device.createCommandEncoder() + const view = this.context.getCurrentTexture().createView() + + const bgPass = encoder.beginRenderPass({ + colorAttachments: [ + { view, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' } + ] + }) + bgPass.setPipeline(this.pipeline) + bgPass.setBindGroup(0, this.bindGroup) + bgPass.draw(3) + bgPass.end() + + this.device.queue.submit([encoder.finish()]) + } +} \ No newline at end of file diff --git a/src/engine/shaders/fullscreen.wgsl b/src/engine/shaders/fullscreen.wgsl new file mode 100644 index 0000000..30d894b --- /dev/null +++ b/src/engine/shaders/fullscreen.wgsl @@ -0,0 +1,104 @@ +struct Uniforms { + resolution: vec2, + time: f32, + scene: f32, + mouse: vec2, + date: vec4, + sampleRate: vec4, + stemMultipliers: array, 8>, +}; + +@group(0) @binding(0) var u: Uniforms; + +struct VsOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs(@builtin(vertex_index) i: u32) -> VsOut { + let p = array, 3>( + vec2(-1.0, -3.0), + vec2(-1.0, 1.0), + vec2(3.0, 1.0), + ); + let clip = p[i]; + var out: VsOut; + out.pos = vec4(clip, 0.0, 1.0); + out.uv = clip; + return out; +} + +@fragment +fn fs(in: VsOut) -> @location(0) vec4 { + let res = u.resolution; + let fragCoord = (in.uv * vec2(0.5) + vec2(0.5)) * res; + + var uv = (fragCoord - 0.5 * res) / res.y; + + let slowTime = u.time * 0.2; + + let bgGrey = vec3(0.11, 0.11, 0.12); + let cardGrey = vec3(0.30, 0.32, 0.36); + let coreWhite = vec3(0.90, 0.95, 0.90); + + // ---------- palette keyed by scene ---------- + let scene0 = vec3(0.20, 0.75, 0.20); // HERO – emerald + let scene1 = vec3(0.85, 0.55, 0.20); // ABOUT – amber + let scene2 = vec3(0.25, 0.60, 0.95); // PROJECTS – cobalt + let scene3 = vec3(0.90, 0.35, 0.55); // CONTACT – rose + + let sFloor = i32(clamp(floor(u.scene), 0.0, 3.0)); + let sCeil = i32(clamp(ceil(u.scene), 0.0, 3.0)); + var c0 = scene0; + c0 = select(c0, scene1, sFloor == 1); + c0 = select(c0, scene2, sFloor == 2); + c0 = select(c0, scene3, sFloor == 3); + var c1 = scene0; + c1 = select(c1, scene1, sCeil == 1); + c1 = select(c1, scene2, sCeil == 2); + c1 = select(c1, scene3, sCeil == 3); + let tBlend = u.scene - floor(u.scene); + let sceneAccent = mix(c0, c1, tBlend * tBlend * (3.0 - 2.0 * tBlend)); + + // ---------- mouse-reactive distortion ---------- + let mousePos = (u.mouse - 0.5 * res) / res.y; + let distMouse = length(uv - mousePos); + let influence = exp(-distMouse * 7.0); + uv += (mousePos - uv) * influence * 0.12; + + let angle = atan2(uv.y, uv.x); + let radius = length(uv); + + let baseSpiral = angle + (radius * 8.0) - slowTime; + let ripple = sin(radius * 15.0 - (u.time * 0.5)) * 0.08; + let distortedSpiral = baseSpiral + ripple; + + let armCount = 2.0; + let rChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral + 0.04) * armCount))); + let gChannel = smoothstep(0.05, 0.55, abs(sin(distortedSpiral * armCount))); + let bChannel = smoothstep(0.05, 0.55, abs(sin((distortedSpiral - 0.04) * armCount))); + let patternChannels = vec3(rChannel, gChannel, bChannel); + + let coreNeon = vec3(0.02 / (patternChannels + 0.025)); + let spiralColor = mix(cardGrey, sceneAccent, patternChannels.g); + let primarySpiral = coreNeon * spiralColor; + + let ringWave = sin(radius * 12.0 - (u.time * 1.5)); + let sharpRing = smoothstep(0.85, 0.95, ringWave); + let ringMask = sharpRing * smoothstep(0.7, 0.1, radius); + let secondaryRings = cardGrey * ringMask * 0.4; + + let vignette = smoothstep(0.95, 0.4, radius); + + var finalColor = bgGrey + secondaryRings; + finalColor = mix(finalColor, primarySpiral, 0.85); + + let centerLight = 0.015 / (radius + 0.02); + finalColor += centerLight * coreWhite; + + // soft cursor glow + finalColor += exp(-distMouse * 4.0) * 0.03 * sceneAccent; + + return vec4(finalColor * vignette, 1.0); +} diff --git a/src/engine/text/slug-generator.ts b/src/engine/text/slug-generator.ts new file mode 100644 index 0000000..397a8b6 --- /dev/null +++ b/src/engine/text/slug-generator.ts @@ -0,0 +1,352 @@ +import type { Font, Glyph, PathCommand } from 'opentype.js' + +const TEXTURE_WIDTH = 4096 + +export interface CodePointData { + codePoint: number + width: number + height: number + advanceWidth: number + bearingX: number + bearingY: number + bandCount: number + bandDimX: number + bandDimY: number + bandsTexCoordX: number + bandsTexCoordY: number +} + +export interface SlugData { + codePoints: Map + curvesData: Float32Array + bandsData: Uint32Array + curvesWidth: number + curvesHeight: number + bandsWidth: number + bandsHeight: number + ascender: number + descender: number + lineGap: number + unitsPerEm: number +} + +interface Curve { + first: boolean + x1: number + y1: number + x2: number + y2: number + x3: number + y3: number + texelIndex: number +} + +export interface GeneratorOptions { + bandCount?: number + fullRange?: boolean + whitelist?: number[] | null +} + +export class SlugGenerator { + private bandCount: number + private fullRange: boolean + private whitelist: number[] | null + + constructor(options: GeneratorOptions = {}) { + this.bandCount = options.bandCount ?? 16 + this.fullRange = options.fullRange ?? false + this.whitelist = options.whitelist ?? null + } + + async generateFromUrl(url: string): Promise { + const opentype = await import('opentype.js') + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to fetch font: ${response.status}`) + const buffer = await response.arrayBuffer() + const font = opentype.parse(buffer) + return this.generate(font) + } + + async generateFromBuffer(buffer: ArrayBuffer): Promise { + const opentype = await import('opentype.js') + const font = opentype.parse(buffer) + return this.generate(font) + } + + generate(font: Font): SlugData { + const curvesTexData: number[] = [] + const bandsTexBandOffsets: number[] = [] + const bandsTexCurveOffsets: number[] = [] + const codePointsData: CodePointData[] = [] + + for (let i = 0; i < font.glyphs.length; i++) { + const glyph: Glyph = font.glyphs.get(i) + let cp = glyph.unicode + if (cp === undefined) { + if (i === 0) cp = -1 + else continue + } + + if (!this.fullRange && i !== 0) { + if (this.whitelist) { + if (!this.whitelist.includes(cp)) continue + } else if (cp < 32 || cp > 126) { + continue + } + } + + const path = glyph.path + const bbox = glyph.getBoundingBox() + if (bbox.x1 === bbox.x2 || bbox.y1 === bbox.y2) { + codePointsData.push({ + codePoint: cp, + width: 0, + height: 0, + advanceWidth: Math.floor(glyph.advanceWidth || 0), + bearingX: 0, + bearingY: 0, + bandCount: 0, + bandDimX: 0, + bandDimY: 0, + bandsTexCoordX: 0, + bandsTexCoordY: 0 + }) + continue + } + + const gx1 = bbox.x1 + const gy1 = bbox.y1 + const gx2 = bbox.x2 + const gy2 = bbox.y2 + + const curves = this.buildCurves(path.commands, gx1, gy1) + if (!curves || curves.length === 0) continue + + this.fixDegenerateCurves(curves) + + const bandsTexelIndex = Math.floor(bandsTexBandOffsets.length / 2) + + this.packCurves(curves, curvesTexData) + + const sizeX = 1 + (gx2 - gx1) + const sizeY = 1 + (gy2 - gy1) + let bCount = this.bandCount + if (sizeX < bCount || sizeY < bCount) { + bCount = Math.floor(Math.min(sizeX, sizeY) / 2) + if (bCount < 1) bCount = 1 + } + + const bandDimY = Math.ceil(sizeY / bCount) + const bandDimX = Math.ceil(sizeX / bCount) + + // Horizontal bands: sort by max X descending + const hSorted = [...curves].sort((a, b) => Math.max(b.x1, b.x2, b.x3) - Math.max(a.x1, a.x2, a.x3)) + let bandMinY = 0 + let bandMaxY = bandDimY + for (let b = 0; b < bCount; b++) { + const bandTexelOffset = Math.floor(bandsTexCurveOffsets.length / 2) + let curveCount = 0 + for (const c of hSorted) { + if (c.y1 === c.y2 && c.y2 === c.y3) continue + const curveMinY = Math.min(c.y1, c.y2, c.y3) + const curveMaxY = Math.max(c.y1, c.y2, c.y3) + if (curveMinY > bandMaxY || curveMaxY < bandMinY) continue + const curveOffsetX = c.texelIndex % TEXTURE_WIDTH + const curveOffsetY = Math.floor(c.texelIndex / TEXTURE_WIDTH) + bandsTexCurveOffsets.push(curveOffsetX, curveOffsetY) + curveCount++ + } + bandsTexBandOffsets.push(curveCount, bandTexelOffset) + bandMinY += bandDimY + bandMaxY += bandDimY + } + + // Vertical bands: sort by max Y descending + const vSorted = [...curves].sort((a, b) => Math.max(b.y1, b.y2, b.y3) - Math.max(a.y1, a.y2, a.y3)) + let bandMinX = 0 + let bandMaxX = bandDimX + for (let b = 0; b < bCount; b++) { + const bandTexelOffset = Math.floor(bandsTexCurveOffsets.length / 2) + let curveCount = 0 + for (const c of vSorted) { + if (c.x1 === c.x2 && c.x2 === c.x3) continue + const curveMinX = Math.min(c.x1, c.x2, c.x3) + const curveMaxX = Math.max(c.x1, c.x2, c.x3) + if (curveMinX > bandMaxX || curveMaxX < bandMinX) continue + const curveOffsetX = c.texelIndex % TEXTURE_WIDTH + const curveOffsetY = Math.floor(c.texelIndex / TEXTURE_WIDTH) + bandsTexCurveOffsets.push(curveOffsetX, curveOffsetY) + curveCount++ + } + bandsTexBandOffsets.push(curveCount, bandTexelOffset) + bandMinX += bandDimX + bandMaxX += bandDimX + } + + codePointsData.push({ + codePoint: cp, + width: Math.floor(gx2 - gx1), + height: Math.floor(gy2 - gy1), + advanceWidth: Math.floor(glyph.advanceWidth || 0), + bearingX: Math.floor(gx1), + bearingY: Math.floor(gy1), + bandCount: bCount, + bandDimX, + bandDimY, + bandsTexCoordX: bandsTexelIndex % TEXTURE_WIDTH, + bandsTexCoordY: Math.floor(bandsTexelIndex / TEXTURE_WIDTH) + }) + } + + // Post-processing: offset curve offsets by band header texel count + const bandHeaderTexels = Math.floor(bandsTexBandOffsets.length / 2) + for (let i = 1; i < bandsTexBandOffsets.length; i += 2) { + bandsTexBandOffsets[i] += bandHeaderTexels + } + + return this.buildOutput(codePointsData, curvesTexData, bandsTexBandOffsets, bandsTexCurveOffsets, font) + } + + private buildCurves( + commands: PathCommand[], + gx1: number, + gy1: number + ): Curve[] | null { + const curves: Curve[] = [] + let currentX = 0 + let currentY = 0 + let firstCurve = false + let startOfShapeX = 0 + let startOfShapeY = 0 + + for (const cmd of commands) { + if (cmd.type === 'M') { + firstCurve = true + currentX = cmd.x - gx1 + currentY = cmd.y - gy1 + startOfShapeX = currentX + startOfShapeY = currentY + } else if (cmd.type === 'L') { + const nextX = cmd.x - gx1 + const nextY = cmd.y - gy1 + curves.push({ + first: firstCurve, + x1: currentX, y1: currentY, + x2: (currentX + nextX) / 2.0, + y2: (currentY + nextY) / 2.0, + x3: nextX, y3: nextY, + texelIndex: 0 + }) + firstCurve = false + currentX = nextX + currentY = nextY + } else if (cmd.type === 'Q') { + const nextX = cmd.x - gx1 + const nextY = cmd.y - gy1 + curves.push({ + first: firstCurve, + x1: currentX, y1: currentY, + x2: cmd.x1 - gx1, y2: cmd.y1 - gy1, + x3: nextX, y3: nextY, + texelIndex: 0 + }) + firstCurve = false + currentX = nextX + currentY = nextY + } else if (cmd.type === 'C') { + return null + } else if (cmd.type === 'Z') { + if (currentX !== startOfShapeX || currentY !== startOfShapeY) { + curves.push({ + first: firstCurve, + x1: currentX, y1: currentY, + x2: (currentX + startOfShapeX) / 2.0, + y2: (currentY + startOfShapeY) / 2.0, + x3: startOfShapeX, y3: startOfShapeY, + texelIndex: 0 + }) + firstCurve = false + } + currentX = startOfShapeX + currentY = startOfShapeY + } + } + + return curves + } + + private fixDegenerateCurves(curves: Curve[]): void { + for (const c of curves) { + if ((c.x2 === c.x1 && c.y2 === c.y1) || (c.x2 === c.x3 && c.y2 === c.y3)) { + c.x2 = (c.x1 + c.x3) / 2.0 + c.y2 = (c.y1 + c.y3) / 2.0 + } + } + } + + private packCurves(curves: Curve[], curvesTexData: number[]): void { + for (const c of curves) { + if (c.first && curvesTexData.length % 4 !== 0) { + const toAdd = 4 - (curvesTexData.length % 4) + for (let i = 0; i < toAdd; i++) curvesTexData.push(-1.0) + } + + const texelCount = Math.floor(curvesTexData.length / 4) + const col = texelCount % TEXTURE_WIDTH + const newRow = col === TEXTURE_WIDTH - 1 + if (newRow) { + const toAdd = 8 - (curvesTexData.length % 4) + for (let i = 0; i < toAdd; i++) curvesTexData.push(-1.0) + } + + if (c.first || newRow) { + c.texelIndex = Math.floor(curvesTexData.length / 4) + curvesTexData.push(c.x1, c.y1) + } else { + c.texelIndex = Math.floor((Math.floor(curvesTexData.length / 2) - 1) / 2) + } + + curvesTexData.push(c.x2, c.y2) + curvesTexData.push(c.x3, c.y3) + } + } + + private buildOutput( + codePoints: CodePointData[], + curvesList: number[], + bandOffsets: number[], + curveOffsets: number[], + font: Font + ): SlugData { + const map = new Map() + codePoints.forEach(cp => map.set(cp.codePoint, cp)) + + const curvesTexels = Math.ceil(curvesList.length / 4) + const curvesTexHeight = Math.max(1, Math.ceil(curvesTexels / TEXTURE_WIDTH)) + + const curvesFloatArray = new Float32Array(TEXTURE_WIDTH * curvesTexHeight * 4) + curvesFloatArray.fill(-1.0) + curvesFloatArray.set(curvesList) + + const bandsTexels = Math.floor(bandOffsets.length / 2) + Math.floor(curveOffsets.length / 2) + const bandsTexHeight = Math.max(1, Math.ceil(bandsTexels / TEXTURE_WIDTH)) + + const bandsUintArray = new Uint32Array(TEXTURE_WIDTH * bandsTexHeight * 2) + bandsUintArray.set(bandOffsets, 0) + bandsUintArray.set(curveOffsets, bandOffsets.length) + + return { + codePoints: map, + curvesData: curvesFloatArray, + bandsData: bandsUintArray, + curvesWidth: TEXTURE_WIDTH, + curvesHeight: curvesTexHeight, + bandsWidth: TEXTURE_WIDTH, + bandsHeight: bandsTexHeight, + ascender: font.ascender || 0, + descender: font.descender || 0, + lineGap: (font as unknown as { lineGap?: number }).lineGap || 0, + unitsPerEm: font.unitsPerEm || 0 + } + } +} \ No newline at end of file diff --git a/src/engine/text/text-layout.ts b/src/engine/text/text-layout.ts new file mode 100644 index 0000000..b694543 --- /dev/null +++ b/src/engine/text/text-layout.ts @@ -0,0 +1,177 @@ +import type { SlugData, CodePointData } from './slug-generator' + +export interface TextOptions { + fontScale?: number + startX?: number + startY?: number + lineHeight?: number + justify?: 'left' | 'center' | 'right' +} + +export interface TextLayoutResult { + /** per-instance: (sx, sy, cx, cy) float */ + scaleBias: Float32Array + /** per-instance: (glyphWidth, glyphHeight, bandScaleX, bandScaleY) float */ + glyphBandScale: Float32Array + /** per-instance: (bandMax, bandMax, bandsTexCoordX, bandsTexCoordY) uint32 */ + bandMaxTexCoords: Uint32Array + glyphCount: number +} + +export function layoutText( + text: string, + slugData: SlugData, + options: TextOptions = {} +): TextLayoutResult { + const ascender = slugData.ascender + const descender = slugData.descender + const lineGap = slugData.lineGap + + const defaultLineHeight = slugData.unitsPerEm > 0 + ? (ascender - descender + lineGap) + : 2000 + + const { + fontScale = 0.5, + lineHeight = defaultLineHeight * fontScale, + startX = 0, + startY = 0, + justify = 'left' + } = options + + const maxGlyphs = countGlyphs(text) + const result: TextLayoutResult = { + scaleBias: new Float32Array(maxGlyphs * 4), + glyphBandScale: new Float32Array(maxGlyphs * 4), + bandMaxTexCoords: new Uint32Array(maxGlyphs * 4), + glyphCount: 0 + } + + const lines = text.split('\n') + let currentY = startY + + for (const line of lines) { + let lineWidth = 0 + let j = 0 + while (j < line.length) { + const charCode = line.codePointAt(j)! + j += charCode > 0xffff ? 2 : 1 + const data = slugData.codePoints.get(charCode) ?? slugData.codePoints.get(-1) + if (data) { + lineWidth += data.advanceWidth * fontScale + } else if (line[j - 1] === ' ') { + lineWidth += 600 * fontScale + } + } + + let currentX = startX + if (justify === 'center') currentX -= lineWidth / 2.0 + else if (justify === 'right') currentX -= lineWidth + + let k = 0 + while (k < line.length) { + const charCode = line.codePointAt(k)! + k += charCode > 0xffff ? 2 : 1 + const data = slugData.codePoints.get(charCode) ?? slugData.codePoints.get(-1) + + if (data) { + if (data.width > 0 && data.height > 0) { + addGlyph(result, data, currentX, currentY, fontScale) + } + currentX += data.advanceWidth * fontScale + } else if (line[k - 1] === ' ') { + currentX += 600 * fontScale + } + } + currentY -= lineHeight + } + + return result +} + +export function measureText( + text: string, + slugData: SlugData, + fontScale: number +): number { + let width = 0 + let j = 0 + while (j < text.length) { + const charCode = text.codePointAt(j)! + j += charCode > 0xffff ? 2 : 1 + const data = slugData.codePoints.get(charCode) ?? slugData.codePoints.get(-1) + if (data) { + width += data.advanceWidth * fontScale + } else if (text[j - 1] === ' ') { + width += 600 * fontScale + } + } + return width +} + +export function mergeLayouts(layouts: TextLayoutResult[]): TextLayoutResult { + const total = layouts.reduce((s, l) => s + l.glyphCount, 0) + const merged: TextLayoutResult = { + scaleBias: new Float32Array(total * 4), + glyphBandScale: new Float32Array(total * 4), + bandMaxTexCoords: new Uint32Array(total * 4), + glyphCount: 0 + } + for (const layout of layouts) { + const n = layout.glyphCount + if (n === 0) continue + const off = merged.glyphCount + merged.scaleBias.set(layout.scaleBias.subarray(0, n * 4), off * 4) + merged.glyphBandScale.set(layout.glyphBandScale.subarray(0, n * 4), off * 4) + merged.bandMaxTexCoords.set(layout.bandMaxTexCoords.subarray(0, n * 4), off * 4) + merged.glyphCount += n + } + return merged +} + +function countGlyphs(text: string): number { + let count = 0 + let j = 0 + while (j < text.length) { + const charCode = text.codePointAt(j)! + j += charCode > 0xffff ? 2 : 1 + count++ + } + return count +} + +function addGlyph( + result: TextLayoutResult, + cpData: CodePointData, + x: number, + y: number, + fontScale: number +): void { + const i = result.glyphCount + const quadW = cpData.width * fontScale + const quadH = cpData.height * fontScale + const px = x + cpData.bearingX * fontScale + const py = y + cpData.bearingY * fontScale + + const sx = quadW / 2.0 + const sy = quadH / 2.0 + const cx = px + sx + const cy = py + sy + + result.scaleBias[i * 4 + 0] = sx + result.scaleBias[i * 4 + 1] = sy + result.scaleBias[i * 4 + 2] = cx + result.scaleBias[i * 4 + 3] = cy + + result.glyphBandScale[i * 4 + 0] = cpData.width + result.glyphBandScale[i * 4 + 1] = cpData.height + result.glyphBandScale[i * 4 + 2] = cpData.width / cpData.bandDimX + result.glyphBandScale[i * 4 + 3] = cpData.height / cpData.bandDimY + + result.bandMaxTexCoords[i * 4 + 0] = cpData.bandCount - 1 + result.bandMaxTexCoords[i * 4 + 1] = cpData.bandCount - 1 + result.bandMaxTexCoords[i * 4 + 2] = cpData.bandsTexCoordX + result.bandMaxTexCoords[i * 4 + 3] = cpData.bandsTexCoordY + + result.glyphCount++ +} \ No newline at end of file diff --git a/src/engine/text/text-renderer.ts b/src/engine/text/text-renderer.ts new file mode 100644 index 0000000..f41b50c --- /dev/null +++ b/src/engine/text/text-renderer.ts @@ -0,0 +1,205 @@ +import shaderSource from './text.wgsl?raw' +import type { SlugData } from './slug-generator' +import type { TextLayoutResult } from './text-layout' + +const TEXT_UNIFORM_FLOATS = 8 // resolution(2) + pad(2) + color(4) +const TEXT_UNIFORM_BYTES = TEXT_UNIFORM_FLOATS * 4 + +const QUAD_VERTICES = new Float32Array([ + -1.0, -1.0, + -1.0, 1.0, + 1.0, 1.0, + -1.0, -1.0, + 1.0, 1.0, + 1.0, -1.0 +]) + +const MAX_GLYPHS = 4096 +const INSTANCE_STRIDE = 4 * 4 + +export class TextRenderer { + private device!: GPUDevice + private pipeline!: GPURenderPipeline + private bindGroup!: GPUBindGroup + private uniformBuffer!: GPUBuffer + private uniformData = new Float32Array(TEXT_UNIFORM_FLOATS) + private quadBuffer!: GPUBuffer + + private scaleBiasBuffer!: GPUBuffer + private glyphBandScaleBuffer!: GPUBuffer + private bandMaxTexCoordsBuffer!: GPUBuffer + + private curvesTex!: GPUTexture + private bandsTex!: GPUTexture + private glyphCount = 0 + private initialized = false + + get ready(): boolean { + return this.initialized + } + + init(device: GPUDevice, format: GPUTextureFormat, slugData: SlugData): void { + this.device = device + + this.createTextures(slugData) + + const module = device.createShaderModule({ code: shaderSource }) + + const bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'unfilterable-float' } }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'uint' } } + ] + }) + + const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }) + + this.pipeline = device.createRenderPipeline({ + layout: pipelineLayout, + vertex: { + module, + entryPoint: 'vs_main', + buffers: [ + { + arrayStride: 2 * 4, + attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }] + }, + { + arrayStride: INSTANCE_STRIDE, + stepMode: 'instance', + attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x4' }] + }, + { + arrayStride: INSTANCE_STRIDE, + stepMode: 'instance', + attributes: [{ shaderLocation: 2, offset: 0, format: 'float32x4' }] + }, + { + arrayStride: INSTANCE_STRIDE, + stepMode: 'instance', + attributes: [{ shaderLocation: 3, offset: 0, format: 'uint32x4' }] + } + ] + }, + fragment: { + module, + entryPoint: 'fs_main', + targets: [{ + format, + blend: { + color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' }, + alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' } + } + }] + }, + primitive: { topology: 'triangle-list' } + }) + + this.uniformBuffer = device.createBuffer({ + size: TEXT_UNIFORM_BYTES, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }) + + this.quadBuffer = device.createBuffer({ + size: QUAD_VERTICES.byteLength, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST + }) + device.queue.writeBuffer(this.quadBuffer, 0, QUAD_VERTICES) + + this.scaleBiasBuffer = device.createBuffer({ + size: MAX_GLYPHS * INSTANCE_STRIDE, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST + }) + this.glyphBandScaleBuffer = device.createBuffer({ + size: MAX_GLYPHS * INSTANCE_STRIDE, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST + }) + this.bandMaxTexCoordsBuffer = device.createBuffer({ + size: MAX_GLYPHS * INSTANCE_STRIDE, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST + }) + + this.bindGroup = device.createBindGroup({ + layout: bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.uniformBuffer } }, + { binding: 1, resource: this.curvesTex.createView() }, + { binding: 2, resource: this.bandsTex.createView() } + ] + }) + + this.initialized = true + } + + private createTextures(slugData: SlugData): void { + this.curvesTex = this.device.createTexture({ + size: { width: slugData.curvesWidth, height: slugData.curvesHeight }, + format: 'rgba32float', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST + }) + this.device.queue.writeTexture( + { texture: this.curvesTex }, + slugData.curvesData.buffer as BufferSource, + { bytesPerRow: slugData.curvesWidth * 4 * 4 }, + { width: slugData.curvesWidth, height: slugData.curvesHeight } + ) + + this.bandsTex = this.device.createTexture({ + size: { width: slugData.bandsWidth, height: slugData.bandsHeight }, + format: 'rg32uint', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST + }) + this.device.queue.writeTexture( + { texture: this.bandsTex }, + slugData.bandsData.buffer as BufferSource, + { bytesPerRow: slugData.bandsWidth * 2 * 4 }, + { width: slugData.bandsWidth, height: slugData.bandsHeight } + ) + } + + updateText(layout: TextLayoutResult): void { + if (!this.initialized) return + const count = Math.min(layout.glyphCount, MAX_GLYPHS) + this.glyphCount = count + if (count === 0) return + + const sb = layout.scaleBias.subarray(0, count * 4) + this.device.queue.writeBuffer(this.scaleBiasBuffer, 0, sb.buffer as BufferSource, sb.byteOffset, sb.byteLength) + const gb = layout.glyphBandScale.subarray(0, count * 4) + this.device.queue.writeBuffer(this.glyphBandScaleBuffer, 0, gb.buffer as BufferSource, gb.byteOffset, gb.byteLength) + const bm = layout.bandMaxTexCoords.subarray(0, count * 4) + this.device.queue.writeBuffer(this.bandMaxTexCoordsBuffer, 0, bm.buffer as BufferSource, bm.byteOffset, bm.byteLength) + } + + renderPass( + encoder: GPUCommandEncoder, + canvasView: GPUTextureView, + resolution: Float32Array, + textColor: Float32Array + ): void { + if (!this.initialized || this.glyphCount === 0) return + + this.uniformData[0] = resolution[0] + this.uniformData[1] = resolution[1] + this.uniformData[4] = textColor[0] + this.uniformData[5] = textColor[1] + this.uniformData[6] = textColor[2] + this.uniformData[7] = textColor[3] + this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData) + + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { view: canvasView, loadOp: 'load', storeOp: 'store' } + ] + }) + pass.setPipeline(this.pipeline) + pass.setBindGroup(0, this.bindGroup) + pass.setVertexBuffer(0, this.quadBuffer) + pass.setVertexBuffer(1, this.scaleBiasBuffer) + pass.setVertexBuffer(2, this.glyphBandScaleBuffer) + pass.setVertexBuffer(3, this.bandMaxTexCoordsBuffer) + pass.draw(6, this.glyphCount) + pass.end() + } +} \ No newline at end of file diff --git a/src/engine/text/text.wgsl b/src/engine/text/text.wgsl new file mode 100644 index 0000000..bd33229 --- /dev/null +++ b/src/engine/text/text.wgsl @@ -0,0 +1,159 @@ +// Text pass: Slug algorithm ported from JSlug (GLSL3 → WGSL). +// Evaluates TrueType quadratic Bézier curves directly in the fragment +// shader for resolution-independent, anti-aliased text rendering. +// No glyph atlas — curves are packed into a float texture, spatially +// banded into a uint texture, and ray-traced per fragment. + +const TEXTURE_WIDTH = 4096u; +const EPSILON = 0.0001; + +struct TextUniforms { + resolution: vec2f, + textColor: vec4f, +} + +@group(0) @binding(0) var u: TextUniforms; +@group(0) @binding(1) var curvesTex: texture_2d; +@group(0) @binding(2) var bandsTex: texture_2d; + +struct VsIn { + @location(0) position: vec2f, + @location(1) aScaleBias: vec4f, + @location(2) aGlyphBandScale: vec4f, + @location(3) aBandMaxTexCoords: vec4u, +} + +struct VsOut { + @builtin(position) pos: vec4f, + @location(0) vTexCoords: vec2f, + @interpolate(flat) @location(1) vGlyphBandScale: vec4f, + @interpolate(flat) @location(2) vBandMaxTexCoords: vec4u, + @interpolate(flat) @location(3) vPixelsPerEm: vec2f, +} + +@vertex +fn vs_main(in: VsIn) -> VsOut { + let px = in.position.x * in.aScaleBias.x + in.aScaleBias.z; + let py = in.position.y * in.aScaleBias.y + in.aScaleBias.w; + var out: VsOut; + out.pos = vec4f( + px / u.resolution.x * 2.0 - 1.0, + py / u.resolution.y * 2.0 - 1.0, + 0.0, 1.0 + ); + out.vTexCoords = in.position * 0.5 + vec2f(0.5); + out.vGlyphBandScale = in.aGlyphBandScale; + out.vBandMaxTexCoords = in.aBandMaxTexCoords; + let ppe = vec2f(2.0 * in.aScaleBias.x, 2.0 * in.aScaleBias.y); + out.vPixelsPerEm = clamp(ppe, vec2f(1.0), vec2f(200.0)); + return out; +} + +// ---------- Slug ray tracer ---------- + +fn trace_ray_curve_h(p1: vec2f, p2: vec2f, p3: vec2f, pixels_per_em: f32) -> f32 { + if (max(max(p1.x, p2.x), p3.x) * pixels_per_em < -0.5) { + return 0.0; + } + + let y_flags = select(0u, 2u, p1.y > 0.0) + + select(0u, 4u, p2.y > 0.0) + + select(0u, 8u, p3.y > 0.0); + let code = (0x2E74u >> y_flags) & 3u; + if (code == 0u) { + return 0.0; + } + + let a = p1 - p2 * 2.0 + p3; + let b = p1 - p2; + let c = p1.y; + let ayr = 1.0 / a.y; + let disc = max(b.y * b.y - a.y * c, 0.0); + let d = sqrt(disc); + var t1 = (b.y - d) * ayr; + var t2 = (b.y + d) * ayr; + + if (abs(a.y) < EPSILON) { + t1 = c / (2.0 * b.y); + t2 = t1; + } + + var coverage = 0.0; + + if ((code & 1u) != 0u) { + let x1 = (a.x * t1 - b.x * 2.0) * t1 + p1.x; + coverage += clamp(x1 * pixels_per_em + 0.5, 0.0, 1.0); + } + + if (code > 1u) { + let x2 = (a.x * t2 - b.x * 2.0) * t2 + p1.x; + coverage -= clamp(x2 * pixels_per_em + 0.5, 0.0, 1.0); + } + + return coverage; +} + +fn trace_ray_band_h( + band_data: vec2u, + pixels_per_em: f32, + v_tex: vec2f, + glyph_scale: vec2f +) -> f32 { + var coverage = 0.0; + for (var curve = 0u; curve < band_data.x; curve++) { + let curve_offset = band_data.y + curve; + let curve_loc = textureLoad(bandsTex, vec2u(curve_offset & 0xFFFu, curve_offset >> 12u), 0).xy; + let p12 = textureLoad(curvesTex, curve_loc, 0) / vec4f(glyph_scale, glyph_scale) - vec4f(v_tex, v_tex); + let p3 = textureLoad(curvesTex, vec2u(curve_loc.x + 1u, curve_loc.y), 0).xy / glyph_scale - v_tex; + coverage += trace_ray_curve_h(p12.xy, p12.zw, p3.xy, pixels_per_em); + } + return coverage; +} + +fn trace_ray_band_v( + band_data: vec2u, + pixels_per_em: f32, + v_tex: vec2f, + glyph_scale: vec2f +) -> f32 { + var coverage = 0.0; + for (var curve = 0u; curve < band_data.x; curve++) { + let curve_offset = band_data.y + curve; + let curve_loc = textureLoad(bandsTex, vec2u(curve_offset & 0xFFFu, curve_offset >> 12u), 0).xy; + let p12 = textureLoad(curvesTex, curve_loc, 0) / vec4f(glyph_scale, glyph_scale) - vec4f(v_tex, v_tex); + let p3 = textureLoad(curvesTex, vec2u(curve_loc.x + 1u, curve_loc.y), 0).xy / glyph_scale - v_tex; + coverage += trace_ray_curve_h(p12.yx, p12.wz, p3.yx, pixels_per_em); + } + return coverage; +} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4f { + let glyph_scale = in.vGlyphBandScale.xy; + let band_scale = in.vGlyphBandScale.zw; + let band_max = in.vBandMaxTexCoords.xy; + let bands_tex_coords = in.vBandMaxTexCoords.zw; + let v_tex = in.vTexCoords; + let pixels_per_em = in.vPixelsPerEm; + + let band_index = clamp( + vec2u(v_tex * band_scale), + vec2u(0u, 0u), + band_max + ); + + let h_band_offset = bands_tex_coords.y * TEXTURE_WIDTH + bands_tex_coords.x + band_index.y; + let h_band_data = textureLoad(bandsTex, vec2u(h_band_offset & 0xFFFu, h_band_offset >> 12u), 0).xy; + + let v_band_offset = bands_tex_coords.y * TEXTURE_WIDTH + bands_tex_coords.x + band_max.y + 1u + band_index.x; + let v_band_data = textureLoad(bandsTex, vec2u(v_band_offset & 0xFFFu, v_band_offset >> 12u), 0).xy; + + var coverage_x = trace_ray_band_h(h_band_data, pixels_per_em.x, v_tex, glyph_scale); + var coverage_y = trace_ray_band_v(v_band_data, pixels_per_em.y, v_tex, glyph_scale); + + coverage_x = min(abs(coverage_x), 1.0); + coverage_y = min(abs(coverage_y), 1.0); + let slug_alpha = (coverage_x + coverage_y) * 0.5; + + return vec4f(u.textColor.rgb, u.textColor.a * slug_alpha); +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..79a31e3 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,16 @@ +import { App } from './app' + +const canvas = document.getElementById('gpu') as HTMLCanvasElement +const fallback = document.getElementById('fallback') as HTMLElement + +async function boot(): Promise { + const app = new App(canvas) + const ok = await app.start() + if (!ok) { + fallback.style.display = 'flex' + canvas.style.display = 'none' + return + } +} + +void boot() \ No newline at end of file diff --git a/src/ui/input.ts b/src/ui/input.ts new file mode 100644 index 0000000..92179b3 --- /dev/null +++ b/src/ui/input.ts @@ -0,0 +1,14 @@ +// Window-level pointer position capture. Discrete clicks and hover are +// handled by the DOM overlay elements directly; this class only feeds the +// continuous pointer position that the background shader consumes. +export class Input { + x = 0 + y = 0 + + constructor() { + window.addEventListener('pointermove', (e) => { + this.x = e.clientX + this.y = e.clientY + }) + } +} \ No newline at end of file diff --git a/src/ui/overlay.ts b/src/ui/overlay.ts new file mode 100644 index 0000000..7a8caf2 --- /dev/null +++ b/src/ui/overlay.ts @@ -0,0 +1,254 @@ +import { RESUME, ResumeData, ResumeEntry, SkillGroup } from '../data/resume' +import { PROJECTS, CATEGORIES, ProjectPage, ProjectCategory } from '../data/projects/index' + +interface SceneContent { + title: string + subtitle: string + body: string +} + +export interface OverlayCallbacks { + onNavClick: (scene: number) => void + onButtonClick: () => void +} + +// ---------- HTML builders ---------- + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>') +} + +function buildContact(data: ResumeData): string { + return data.contact + .map((c, i) => { + const sep = i > 0 ? '·' : '' + if (c.href) return `${sep}
${esc(c.label)}` + return `${sep}${esc(c.label)}` + }) + .join('\n') +} + +function buildEntryCard(e: ResumeEntry): string { + const bullets = e.bullets.length + ? `
    ${e.bullets.map((b) => `
  • ${b}
  • `).join('')}
` + : '' + const thesis = e.thesis ? `
${esc(e.thesis)}
` : '' + return `
+
${esc(e.date)}
+
${esc(e.role)}
+
${esc(e.company)} — ${esc(e.location)}
+ ${thesis}${bullets} +
` +} + +function buildSkillGroup(g: SkillGroup): string { + const pills = g.skills.map((s) => `${esc(s)}`).join('') + return `
+

${esc(g.label)}

+
${pills}
+
` +} + +function buildResumeHTML(data: ResumeData): string { + const workCards = data.work.map(buildEntryCard).join('') + const eduCards = data.education.map(buildEntryCard).join('') + const skillGroups = data.skills.map(buildSkillGroup).join('') + return `
+
+

${esc(data.name)}

+
${buildContact(data)}
+
${esc(data.location)}
+
+
+

Summary

+

${esc(data.summary)}

+
+
+

Work Experience

+ ${workCards} +
+
+

Education

+ ${eduCards} +
+
+

Skills

+ ${skillGroups} +
+
` +} + +// ---------- project HTML builders ---------- + +function buildCategoryGridHTML(categories: ProjectCategory[]): string { + const cards = categories + .map( + (c) => `
+

${esc(c.label)}

+

${esc(c.description)}

+ ${c.projects.length} project${c.projects.length === 1 ? '' : 's'} +
` + ) + .join('') + return `
${cards}
` +} + +function buildCategoryListHTML(cat: ProjectCategory): string { + if (cat.projects.length === 0) { + return `
+ +

Nothing here yet.

+
` + } + const cards = cat.projects + .map( + (p) => `
+
${esc(p.date)}
+

${esc(p.name)}

+
${esc(p.tagline)}
+
${p.tech.map((t) => `${esc(t)}`).join('')}
+
` + ) + .join('') + return `
+ + ${cards} +
` +} + +function buildProjectDetailHTML(p: ProjectPage, catId: string): string { + return `
+ +
${esc(p.date)}
+

${esc(p.name)}

+
${esc(p.tagline)}
+
${p.tech.map((t) => `${esc(t)}`).join('')}
+
${p.content}
+
` +} + +// ---------- overlay ---------- + +export class DomOverlay { + private readonly navItems: HTMLElement[] + private readonly titleEl: HTMLElement + private readonly subtitleEl: HTMLElement + private readonly bodyEl: HTMLElement + private readonly sceneContentEl: HTMLElement + private readonly projectsSectionEl: HTMLElement + private readonly resumeSectionEl: HTMLElement + private readonly cycleBtnEl: HTMLElement + private readonly scenes: SceneContent[] + private projectsBuilt = false + private resumeBuilt = false + + constructor(scenes: SceneContent[], callbacks: OverlayCallbacks) { + this.scenes = scenes + this.navItems = Array.from(document.querySelectorAll('[data-nav]')) + this.titleEl = document.getElementById('scene-title') as HTMLElement + this.subtitleEl = document.getElementById('scene-subtitle') as HTMLElement + this.bodyEl = document.getElementById('scene-body') as HTMLElement + this.sceneContentEl = document.getElementById('scene-content') as HTMLElement + this.projectsSectionEl = document.getElementById('projects-section') as HTMLElement + this.resumeSectionEl = document.getElementById('resume-section') as HTMLElement + this.cycleBtnEl = document.getElementById('cycle-button') as HTMLElement + + this.navItems.forEach((item, i) => { + item.addEventListener('click', () => callbacks.onNavClick(i)) + }) + + this.cycleBtnEl.addEventListener('click', () => callbacks.onButtonClick()) + + // delegate clicks inside projects section + this.projectsSectionEl.addEventListener('click', (e) => { + const target = e.target as HTMLElement + + // category card click → open category + const catCard = target.closest('.cat-card') + if (catCard && catCard.dataset.catId) { + this.showCategory(catCard.dataset.catId) + return + } + + // project card click → open detail + const projCard = target.closest('.project-card') + if (projCard && projCard.dataset.projectId) { + // find which category this project belongs to + const cat = CATEGORIES.find((c) => + c.projects.some((p) => p.id === projCard.dataset.projectId) + ) + if (cat) { + this.showProjectDetail(projCard.dataset.projectId, cat.id) + } + return + } + + // back button + const back = target.closest('.project-back') + if (back && back.dataset.back === 'grid') { + this.showCategoryGrid() + return + } + if (back && back.dataset.back === 'cat' && back.dataset.catId) { + this.showCategory(back.dataset.catId) + return + } + }) + } + + private showCategoryGrid(): void { + this.projectsSectionEl.innerHTML = buildCategoryGridHTML(CATEGORIES) + } + + private showCategory(id: string): void { + const cat = CATEGORIES.find((c) => c.id === id) + if (!cat) return + this.projectsSectionEl.innerHTML = buildCategoryListHTML(cat) + } + + private showProjectDetail(projectId: string, catId: string): void { + const p = PROJECTS.find((p) => p.id === projectId) + if (!p) return + this.projectsSectionEl.innerHTML = buildProjectDetailHTML(p, catId) + } + + setScene(scene: number): void { + const content = this.scenes[scene] + if (content) { + this.titleEl.textContent = content.title + this.subtitleEl.textContent = content.subtitle + this.bodyEl.textContent = content.body + } + + // default: show scene-content, hide special sections + this.sceneContentEl.classList.remove('hidden') + this.projectsSectionEl.classList.remove('visible') + this.resumeSectionEl.classList.remove('visible') + this.cycleBtnEl.style.display = '' + + if (scene === 2) { + this.sceneContentEl.classList.add('hidden') + this.projectsSectionEl.classList.add('visible') + this.cycleBtnEl.style.display = 'none' + if (!this.projectsBuilt) { + this.showCategoryGrid() + this.projectsBuilt = true + } else { + // always reset to category grid when landing on projects + this.showCategoryGrid() + } + } else if (scene === 4) { + this.sceneContentEl.classList.add('hidden') + this.resumeSectionEl.classList.add('visible') + this.cycleBtnEl.style.display = 'none' + if (!this.resumeBuilt) { + this.resumeSectionEl.innerHTML = buildResumeHTML(RESUME) + this.resumeBuilt = true + } + } + + this.navItems.forEach((item, i) => { + item.classList.toggle('active', i === scene) + }) + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f077db6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["@webgpu/types", "vite/client"], + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..6a72aaf --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { open: true }, + build: { target: 'esnext' } +}) \ No newline at end of file