This commit is contained in:
2026-09-15 17:58:27 +02:00
parent bdacc4a895
commit f400f63321
6 changed files with 360 additions and 104 deletions
+66 -1
View File
@@ -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",
+3 -1
View File
@@ -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"
+13 -102
View File
@@ -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: (
<svg viewBox="0 0 460 210" className="case-svg" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="40" y="20" width="380" height="170" rx="3" stroke="var(--ink)" strokeWidth="1.5" />
<line x1="56" y1="20" x2="56" y2="190" stroke="var(--border)" strokeWidth="1" strokeDasharray="2 2" />
<line x1="404" y1="20" x2="404" y2="190" stroke="var(--border)" strokeWidth="1" strokeDasharray="2 2" />
<rect x="66" y="30" width="328" height="24" rx="2" fill="var(--canvas)" stroke="var(--ink)" strokeWidth="1" />
<circle cx="85" cy="42" r="3" fill="var(--ink)" />
<circle cx="98" cy="42" r="3" fill="var(--ink)" />
<circle cx="111" cy="42" r="3" fill="var(--ink)" />
<circle cx="124" cy="42" r="3" fill="var(--ink)" />
<circle cx="340" cy="42" r="2.5" fill="var(--accent)" className="led-pulse" />
<text x="355" y="45" fill="var(--secondary)" fontFamily="Space Mono" fontSize="8">PATCH</text>
<rect x="66" y="62" width="328" height="68" rx="2" fill="var(--canvas)" stroke="var(--ink)" strokeWidth="1.2" />
<rect x="76" y="72" width="32" height="48" rx="1.5" stroke="var(--ink)" strokeWidth="1" />
<circle cx="92" cy="112" r="1.5" fill="var(--accent)" />
<rect x="114" y="72" width="32" height="48" rx="1.5" stroke="var(--ink)" strokeWidth="1" />
<circle cx="130" cy="112" r="1.5" fill="var(--ink)" />
<rect x="152" y="72" width="32" height="48" rx="1.5" stroke="var(--ink)" strokeWidth="1" />
<circle cx="168" cy="112" r="1.5" fill="var(--ink)" />
<rect x="190" y="72" width="32" height="48" rx="1.5" stroke="var(--ink)" strokeWidth="1" />
<circle cx="206" cy="112" r="1.5" fill="var(--ink)" />
<rect x="238" y="72" width="32" height="48" rx="1.5" stroke="var(--ink)" strokeWidth="1" />
<circle cx="254" cy="112" r="1.5" fill="var(--ink)" />
<rect x="276" y="72" width="32" height="48" rx="1.5" stroke="var(--ink)" strokeWidth="1" />
<circle cx="292" cy="112" r="1.5" fill="var(--ink)" />
<rect x="314" y="72" width="32" height="48" rx="1.5" stroke="var(--ink)" strokeWidth="1" />
<circle cx="330" cy="112" r="1.5" fill="var(--ink)" />
<rect x="352" y="72" width="32" height="48" rx="1.5" stroke="var(--ink)" strokeWidth="1" />
<circle cx="368" cy="112" r="1.5" fill="var(--accent)" />
<rect x="66" y="138" width="328" height="18" rx="2" fill="var(--canvas)" stroke="var(--border)" strokeWidth="1" />
<path d="M 85 147 Q 160 165 240 147 T 370 147" stroke="var(--accent)" strokeWidth="1.2" fill="none" className="bus-flow" />
<rect x="66" y="162" width="328" height="22" rx="2" fill="var(--canvas)" stroke="var(--ink)" strokeWidth="1" />
<line x1="80" y1="173" x2="130" y2="173" stroke="var(--ink)" strokeWidth="1.5" />
<circle cx="370" cy="173" r="2" fill="var(--ink)" />
</svg>
),
diagram: <ThreeViewport build={buildRackScene} className="case-canvas" />,
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: (
<svg viewBox="0 0 460 210" className="case-svg" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="25" y="25" width="190" height="160" rx="3" stroke="var(--ink)" strokeWidth="1.2" fill="var(--canvas)" />
<rect x="75" y="65" width="46" height="46" rx="2" stroke="var(--ink)" strokeWidth="1.5" fill="#FFFFFF" />
<text x="98" y="92" fill="var(--ink)" fontFamily="Space Mono" fontSize="9" textAnchor="middle" fontWeight="bold">NODE 01</text>
<line x1="130" y1="60" x2="130" y2="116" stroke="var(--ink)" strokeWidth="1" />
<line x1="136" y1="60" x2="136" y2="116" stroke="var(--ink)" strokeWidth="1" />
<line x1="142" y1="60" x2="142" y2="116" stroke="var(--accent)" strokeWidth="1" />
<line x1="148" y1="60" x2="148" y2="116" stroke="var(--ink)" strokeWidth="1" />
<rect x="40" y="135" width="120" height="14" rx="1" stroke="var(--ink)" strokeWidth="1" fill="#FFFFFF" />
<text x="100" y="145" fill="var(--secondary)" fontFamily="Space Mono" fontSize="7" textAnchor="middle">PCIe // TENSOR LOAD</text>
<path d="M 215 105 L 245 105" stroke="var(--accent)" strokeWidth="2" strokeDasharray="3 3" className="bus-flow" />
<rect x="245" y="25" width="190" height="160" rx="3" stroke="var(--ink)" strokeWidth="1.2" fill="var(--canvas)" />
<rect x="295" y="65" width="46" height="46" rx="2" stroke="var(--ink)" strokeWidth="1.5" fill="#FFFFFF" />
<text x="318" y="92" fill="var(--ink)" fontFamily="Space Mono" fontSize="9" textAnchor="middle" fontWeight="bold">NODE 02</text>
<line x1="350" y1="60" x2="350" y2="116" stroke="var(--ink)" strokeWidth="1" />
<line x1="356" y1="60" x2="356" y2="116" stroke="var(--ink)" strokeWidth="1" />
<line x1="362" y1="60" x2="362" y2="116" stroke="var(--accent)" strokeWidth="1" />
<line x1="368" y1="60" x2="368" y2="116" stroke="var(--ink)" strokeWidth="1" />
<rect x="260" y="135" width="120" height="14" rx="1" stroke="var(--ink)" strokeWidth="1" fill="#FFFFFF" />
<text x="320" y="145" fill="var(--secondary)" fontFamily="Space Mono" fontSize="7" textAnchor="middle">STORAGE &amp; GITEA</text>
</svg>
),
diagram: <ThreeViewport build={buildClusterScene} className="case-canvas" />,
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: (
<svg viewBox="0 0 460 210" className="case-svg" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="105" r="24" stroke="var(--ink)" strokeWidth="1.2" fill="var(--canvas)" />
<text x="50" y="103" fill="var(--ink)" fontFamily="Space Mono" fontSize="8" textAnchor="middle" fontWeight="bold">12+ USERS</text>
<text x="50" y="115" fill="var(--secondary)" fontFamily="Space Mono" fontSize="7" textAnchor="middle">SSO / 2FA</text>
<path d="M 74 105 L 146 105" stroke="var(--accent)" strokeWidth="1.5" strokeDasharray="3 3" className="bus-flow" />
<rect x="146" y="70" width="80" height="70" rx="3" stroke="var(--ink)" strokeWidth="1.2" fill="var(--canvas)" />
<text x="186" y="98" fill="var(--ink)" fontFamily="Space Mono" fontSize="8" textAnchor="middle" fontWeight="bold">CLOUDFLARE</text>
<text x="186" y="112" fill="var(--accent)" fontFamily="Space Mono" fontSize="7" textAnchor="middle">TUNNELS</text>
<text x="186" y="124" fill="var(--secondary)" fontFamily="Space Mono" fontSize="6" textAnchor="middle">WIREGUARD MESH</text>
<path d="M 226 105 L 280 105" stroke="var(--ink)" strokeWidth="1.2" />
<line x1="280" y1="50" x2="280" y2="160" stroke="var(--ink)" strokeWidth="1.2" />
<line x1="280" y1="50" x2="310" y2="50" stroke="var(--ink)" strokeWidth="1.2" />
<rect x="310" y="32" width="125" height="36" rx="2" stroke="var(--ink)" strokeWidth="1" fill="#FFFFFF" />
<text x="322" y="47" fill="var(--ink)" fontFamily="Space Mono" fontSize="8" fontWeight="bold">UNRAID HOST</text>
<text x="322" y="58" fill="var(--secondary)" fontFamily="Space Mono" fontSize="7">Docker Containers</text>
<line x1="280" y1="105" x2="310" y2="105" stroke="var(--ink)" strokeWidth="1.2" />
<rect x="310" y="87" width="125" height="36" rx="2" stroke="var(--ink)" strokeWidth="1" fill="#FFFFFF" />
<text x="322" y="102" fill="var(--ink)" fontFamily="Space Mono" fontSize="8" fontWeight="bold">PROXMOX VE 8</text>
<text x="322" y="113" fill="var(--secondary)" fontFamily="Space Mono" fontSize="7">KVM / LXC Workloads</text>
<line x1="280" y1="160" x2="310" y2="160" stroke="var(--ink)" strokeWidth="1.2" />
<rect x="310" y="142" width="125" height="36" rx="2" stroke="var(--ink)" strokeWidth="1" fill="#FFFFFF" />
<text x="322" y="157" fill="var(--ink)" fontFamily="Space Mono" fontSize="8" fontWeight="bold">TRUENAS APPLIANCE</text>
<text x="322" y="168" fill="var(--secondary)" fontFamily="Space Mono" fontSize="7">ZFS RAID 10</text>
</svg>
),
diagram: <ThreeViewport build={buildNasScene} className="case-canvas" />,
interactive: true,
},
]
@@ -350,7 +261,7 @@ export default function App() {
<div className="diagram-card">
<div className="diagram-row diagram-header">
<span>{c.headerL}</span>
<span>{c.headerR}</span>
<span className={c.interactive ? 'diagram-interactive' : undefined}>{c.headerR}</span>
</div>
<div className="diagram-svg-wrap">{c.diagram}</div>
<div className="diagram-row diagram-footer">
+68
View File
@@ -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<HTMLDivElement>(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 <div ref={containerRef} className={className} />
}
+15
View File
@@ -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}
}
+195
View File
@@ -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()
},
}
}