From f400f63321a15cb7b4eac6a3240973133988c852 Mon Sep 17 00:00:00 2001 From: Quinta0 <0pietroquintavalle0@gmail.com> Date: Tue, 15 Sep 2026 17:58:27 +0200 Subject: [PATCH] 3js --- package-lock.json | 67 ++++++++++++++- package.json | 4 +- src/App.tsx | 115 +++---------------------- src/ThreeViewport.tsx | 68 +++++++++++++++ src/index.css | 15 ++++ src/scenes.ts | 195 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 360 insertions(+), 104 deletions(-) create mode 100644 src/ThreeViewport.tsx create mode 100644 src/scenes.ts diff --git a/package-lock.json b/package-lock.json index 157f878..8306f32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,25 @@ "version": "0.1.0", "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "three": "^0.186.0" }, "devDependencies": { "@types/react": "^18.3.8", "@types/react-dom": "^18.3.0", + "@types/three": "^0.186.0", "@vitejs/plugin-react": "^6.0.5", "typescript": "^5.6.2", "vite": "^8.2.1" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@oxc-project/types": { "version": "0.143.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", @@ -292,6 +301,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -320,6 +336,35 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz", + "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.3", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", @@ -381,6 +426,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -687,6 +739,13 @@ "loose-envify": "cli.js" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -832,6 +891,12 @@ "node": ">=0.10.0" } }, + "node_modules/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.0.tgz", + "integrity": "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/package.json b/package.json index ef80880..a1055a1 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,13 @@ }, "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "three": "^0.186.0" }, "devDependencies": { "@types/react": "^18.3.8", "@types/react-dom": "^18.3.0", + "@types/three": "^0.186.0", "@vitejs/plugin-react": "^6.0.5", "typescript": "^5.6.2", "vite": "^8.2.1" diff --git a/src/App.tsx b/src/App.tsx index 94b438f..535ef29 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from 'react' +import ThreeViewport from './ThreeViewport' +import { buildRackScene, buildClusterScene, buildNasScene } from './scenes' const EMAIL = '0pietroquintavalle0@gmail.com' const GITHUB = 'https://github.com/Quinta0' @@ -15,6 +17,7 @@ type CaseStudy = { footerL: string footerR: string diagram: ReactNode + interactive?: boolean } const cases: CaseStudy[] = [ @@ -25,50 +28,11 @@ const cases: CaseStudy[] = [ tags: ['19" EIA-310 Cabinet', 'ZFS RAID 10', 'Structured Cabling'], tint: '#EFECE6', headerL: 'RACK_CHASSIS // EIA-310', - headerR: 'ZFS RAID 10 POOL', + headerR: '• DRAG TO ROTATE', footerL: 'STRUCTURED HARNESS', footerR: 'SUB-30M RTO SYNC', - diagram: ( - - - - - - - - - - - - PATCH - - - - - - - - - - - - - - - - - - - - - - - - - - - - ), + diagram: , + interactive: true, }, { label: '02 / Startup Infrastructure • DEC Energy', @@ -77,34 +41,11 @@ const cases: CaseStudy[] = [ tags: ['Bare-Metal K8s', 'LLM Inference', 'Thermal Profiling'], tint: '#EAE8E1', headerL: 'DUAL_NODE // TOPOLOGY', - headerR: 'BARE-METAL FABRIC', + headerR: '• DRAG TO ROTATE', footerL: 'ON-PREMISES INGESTION', footerR: 'USI STARTUP', - diagram: ( - - - - NODE 01 - - - - - - PCIe // TENSOR LOAD - - - - - - NODE 02 - - - - - - STORAGE & GITEA - - ), + diagram: , + interactive: true, }, { label: '03 / Protocol Reverse Engineering', @@ -151,41 +92,11 @@ const cases: CaseStudy[] = [ tags: ['Zero-Trust WAN', 'Ansible IaC', 'Proxmox / UNRAID'], tint: '#EDEAE3', headerL: 'TOPOLOGY MAP', - headerR: 'ZERO EXPOSED WAN', + headerR: '• HOVER BAYS', footerL: 'ANSIBLE PROVISIONED', footerR: 'DAILY PRODUCTION', - diagram: ( - - - 12+ USERS - SSO / 2FA - - - - - CLOUDFLARE - TUNNELS - WIREGUARD MESH - - - - - - - UNRAID HOST - Docker Containers - - - - PROXMOX VE 8 - KVM / LXC Workloads - - - - TRUENAS APPLIANCE - ZFS RAID 10 - - ), + diagram: , + interactive: true, }, ] @@ -350,7 +261,7 @@ export default function App() {
{c.headerL} - {c.headerR} + {c.headerR}
{c.diagram}
diff --git a/src/ThreeViewport.tsx b/src/ThreeViewport.tsx new file mode 100644 index 0000000..261f80b --- /dev/null +++ b/src/ThreeViewport.tsx @@ -0,0 +1,68 @@ +import { useEffect, useRef } from 'react' +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' + +export type ViewportHandle = { + update?: () => void + dispose?: () => void +} + +export type BuildFn = ( + scene: THREE.Scene, + camera: THREE.PerspectiveCamera, + controls: OrbitControls, + renderer: THREE.WebGLRenderer, + container: HTMLDivElement, +) => ViewportHandle | void + +export default function ThreeViewport({ build, className }: { build: BuildFn; className?: string }) { + const containerRef = useRef(null) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + const scene = new THREE.Scene() + const camera = new THREE.PerspectiveCamera(38, container.clientWidth / container.clientHeight, 0.1, 100) + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) + renderer.setSize(container.clientWidth, container.clientHeight) + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + container.appendChild(renderer.domElement) + + const controls = new OrbitControls(camera, renderer.domElement) + controls.enableDamping = true + controls.dampingFactor = 0.05 + controls.enableZoom = false + controls.autoRotate = true + controls.autoRotateSpeed = 1 + + const handle = build(scene, camera, controls, renderer, container) ?? undefined + + let frameId = 0 + const animate = () => { + frameId = requestAnimationFrame(animate) + controls.update() + handle?.update?.() + renderer.render(scene, camera) + } + animate() + + const onResize = () => { + camera.aspect = container.clientWidth / container.clientHeight + camera.updateProjectionMatrix() + renderer.setSize(container.clientWidth, container.clientHeight) + } + window.addEventListener('resize', onResize) + + return () => { + cancelAnimationFrame(frameId) + window.removeEventListener('resize', onResize) + handle?.dispose?.() + controls.dispose() + renderer.dispose() + container.removeChild(renderer.domElement) + } + }, [build]) + + return
+} diff --git a/src/index.css b/src/index.css index cd77304..97e189a 100644 --- a/src/index.css +++ b/src/index.css @@ -109,6 +109,20 @@ body{ .diagram-footer{padding-top:12px;border-top:1px solid rgba(230,228,221,.4);font-size:10px} .diagram-svg-wrap{width:100%;overflow-x:auto;padding:8px 0} .case-svg{width:100%;min-width:320px;height:auto} +.diagram-interactive{color:var(--accent);font-weight:600} + +.case-canvas{ + position:relative;width:100%;height:288px;border-radius:6px;overflow:hidden; + cursor:grab;touch-action:pan-y; +} +.case-canvas:active{cursor:grabbing} +.case-canvas canvas{display:block;touch-action:pan-y} +.nas-hud{ + position:absolute;z-index:5;pointer-events:none; + background:var(--ink);color:var(--canvas); + font-family:var(--f-mono);font-size:10px; + padding:4px 8px;border-radius:4px;box-shadow:0 1px 2px rgba(0,0,0,.2); +} .led-pulse{animation:ledBlink 2.4s ease-in-out infinite} @keyframes ledBlink{0%,100%{opacity:.3}50%{opacity:1}} @@ -197,5 +211,6 @@ body{ .case-desc{font-size:12px} .case-diagram{padding:16px;border-radius:12px} .diagram-card{padding:16px} + .case-canvas{height:256px} .site-footer{padding:48px var(--pad);flex-direction:column;align-items:flex-start} } diff --git a/src/scenes.ts b/src/scenes.ts new file mode 100644 index 0000000..d5e8883 --- /dev/null +++ b/src/scenes.ts @@ -0,0 +1,195 @@ +import * as THREE from 'three' +import type { BuildFn } from './ThreeViewport' + +const wireMat = new THREE.LineBasicMaterial({ color: 0x121212 }) +const chassisMat = new THREE.MeshBasicMaterial({ color: 0xf7f6f2, transparent: true, opacity: 0.75 }) +const darkPartMat = new THREE.MeshBasicMaterial({ color: 0x1c1b19 }) +const swissRedMat = new THREE.MeshBasicMaterial({ color: 0xc1121f }) +const copperMat = new THREE.MeshBasicMaterial({ color: 0xb9814f }) +const tealDriveMat = new THREE.MeshBasicMaterial({ color: 0x1f8f86, transparent: true, opacity: 0.75 }) + +export const buildRackScene: BuildFn = (scene, camera) => { + camera.position.set(4, 2.5, 4.5) + const rack = new THREE.Group() + rack.add(new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.6, 2.8, 1.4)), wireMat)) + + for (let i = 0; i < 4; i++) { + const server = new THREE.Mesh(new THREE.BoxGeometry(1.45, 0.38, 1.25), chassisMat) + server.position.set(0, -0.9 + i * 0.55, 0) + server.add(new THREE.LineSegments(new THREE.EdgesGeometry(server.geometry), wireMat)) + + for (let d = 0; d < 4; d++) { + const drive = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.22, 0.05), darkPartMat) + drive.position.set(-0.45 + d * 0.3, 0, 0.63) + server.add(drive) + } + + const led = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.04, 0.04), swissRedMat) + led.position.set(0.6, 0.1, 0.64) + server.add(led) + rack.add(server) + } + + scene.add(rack) +} + +export const buildClusterScene: BuildFn = (scene, camera) => { + camera.position.set(3.8, 3.5, 3.8) + const cluster = new THREE.Group() + + const makeBoard = (x: number) => { + const board = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.08, 1.8), chassisMat) + board.position.set(x, 0, 0) + board.add(new THREE.LineSegments(new THREE.EdgesGeometry(board.geometry), wireMat)) + const cpu = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.12, 0.45), darkPartMat) + cpu.position.set(0, 0.06, -0.2) + board.add(cpu) + return board + } + + const b1 = makeBoard(-0.9) + const gpu = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.4, 1.2), darkPartMat) + gpu.position.set(0.5, 0.2, 0.1) + b1.add(gpu) + cluster.add(b1) + + const b2 = makeBoard(0.9) + const hba = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.35, 1), darkPartMat) + hba.position.set(-0.5, 0.18, 0.1) + b2.add(hba) + cluster.add(b2) + + const bridge = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.04, 0.06), swissRedMat) + bridge.position.set(0, 0.08, -0.2) + cluster.add(bridge) + + scene.add(cluster) +} + +function makeGrilleTexture() { + const c = document.createElement('canvas') + c.width = c.height = 128 + const ctx = c.getContext('2d')! + ctx.fillStyle = '#1a1a1a' + ctx.fillRect(0, 0, 128, 128) + ctx.fillStyle = '#3a3a3a' + for (let x = 5; x < 128; x += 10) { + for (let y = 5; y < 128; y += 10) ctx.fillRect(x - 1.5, y - 1.5, 3, 3) + } + const tex = new THREE.CanvasTexture(c) + tex.wrapS = tex.wrapT = THREE.RepeatWrapping + tex.repeat.set(2, 2) + return tex +} + +export const buildNasScene: BuildFn = (scene, camera, controls, renderer, container) => { + camera.position.set(3.6, 2.6, 3.8) + + const nas = new THREE.Group() + const W = 2.3, H = 2.4, D = 2.6 + const halfH = H / 2 + const stripY = 0.25 + const bayTopY = stripY - 0.15 + const bayHeight = bayTopY - (-halfH + 0.25) + + nas.add(new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(W, H, D)), wireMat)) + + const grilleTex = makeGrilleTexture() + const topMat = new THREE.MeshBasicMaterial({ map: grilleTex, transparent: true, opacity: 0.85 }) + const top = new THREE.Mesh(new THREE.PlaneGeometry(W - 0.12, D - 0.12), topMat) + top.rotation.x = -Math.PI / 2 + top.position.y = halfH - 0.05 + nas.add(top) + + const strip = new THREE.Group() + strip.add(new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(W, 0.26, 0.08)), new THREE.LineBasicMaterial({ color: 0xb9814f }))) + const stripFace = new THREE.Mesh(new THREE.PlaneGeometry(W - 0.04, 0.22), copperMat) + stripFace.position.z = 0.045 + strip.add(stripFace) + strip.position.set(0, stripY, D / 2 + 0.02) + nas.add(strip) + + const bayWidth = 0.17, gap = 0.02 + const startX = -(9 * bayWidth + 8 * gap) / 2 + bayWidth / 2 + const hitMeshes: THREE.Mesh[] = [] + const leds: { mat: THREE.MeshBasicMaterial; phase: number }[] = [] + + for (let i = 0; i < 9; i++) { + const x = startX + i * (bayWidth + gap) + const bay = new THREE.Group() + bay.position.set(x, bayTopY - bayHeight / 2, D / 2) + bay.add(new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(bayWidth, bayHeight, 0.18)), wireMat)) + + const drive = new THREE.Mesh(new THREE.BoxGeometry(bayWidth - 0.03, bayHeight - 0.12, 0.32), tealDriveMat) + drive.position.z = -0.16 + bay.add(drive) + + const hitMesh = new THREE.Mesh(new THREE.BoxGeometry(bayWidth, bayHeight, 0.5), new THREE.MeshBasicMaterial({ transparent: true, opacity: 0 })) + hitMesh.position.z = -0.1 + hitMesh.userData.label = `BAY ${String(i + 1).padStart(2, '0')} // WD RED 10TB (ONLINE)` + bay.add(hitMesh) + hitMeshes.push(hitMesh) + + const ledMat = new THREE.MeshBasicMaterial({ color: 0xc1121f, transparent: true, opacity: 0.5 }) + const led = new THREE.Mesh(new THREE.CircleGeometry(0.018, 8), ledMat) + led.position.set(0, bayHeight / 2 - 0.08, 0.1) + bay.add(led) + leds.push({ mat: ledMat, phase: i * 0.5 }) + + nas.add(bay) + } + + scene.add(nas) + + const hud = document.createElement('div') + hud.className = 'nas-hud' + hud.hidden = true + container.appendChild(hud) + + const raycaster = new THREE.Raycaster() + const pointer = new THREE.Vector2() + + const onPointerMove = (e: PointerEvent) => { + const rect = renderer.domElement.getBoundingClientRect() + pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1 + pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1 + + raycaster.setFromCamera(pointer, camera) + const hit = raycaster.intersectObjects(hitMeshes)[0] + + if (hit) { + hud.textContent = hit.object.userData.label + hud.style.left = `${Math.min(e.clientX - rect.left + 12, rect.width - 180)}px` + hud.style.top = `${Math.max(e.clientY - rect.top - 28, 10)}px` + hud.hidden = false + controls.autoRotate = false + } else { + hud.hidden = true + controls.autoRotate = true + } + } + + const onPointerLeave = () => { + hud.hidden = true + controls.autoRotate = true + } + + renderer.domElement.addEventListener('pointermove', onPointerMove) + renderer.domElement.addEventListener('pointerleave', onPointerLeave) + + const clock = new THREE.Clock() + + return { + update: () => { + const t = clock.getElapsedTime() + for (const l of leds) l.mat.opacity = 0.3 + Math.abs(Math.sin(t * 1.6 + l.phase)) * 0.6 + }, + dispose: () => { + renderer.domElement.removeEventListener('pointermove', onPointerMove) + renderer.domElement.removeEventListener('pointerleave', onPointerLeave) + container.removeChild(hud) + grilleTex.dispose() + topMat.dispose() + }, + } +}