feat: PCB journey — 3D components + trace current + contact popup
WebGL Background: - Camera follows copper trace path (CatmullRom curve) on scroll - Green PCB substrate with solder mask grid - Main copper trace tube + 10 secondary branch traces - 15 vias (metallic rings with holes) - Current flow: 60 cyan + 30 amber particles along traces - 200 ambient dust particles 3D Electronic Components: - IC/MCU (ESP32, LM358, IRFZ44, LM7805) with pins + silkscreen - Capacitors (cylinders, electrolytic + ceramic) - Resistors (SMD rectangles with solder pads) - LEDs (glowing domes, intensity = scroll proximity) - USB-C connector Section mapping on PCB: - Hero = ESP32 MCU area - About = Op-amp + passives - Cases = Power MOSFET stage - Media = USB connector - Sprints = Voltage regulator - Contact = Energy storage capacitors Contact 3D popup: - Hidden by default, opens on #contact click or scroll >95% - 3D perspective entry (rotateY -15deg → 0) - Glassmorphism backdrop blur - Close via backdrop click, X button, or Escape CSS: - Orbital card entry animations (left/right alternating) - Atomic carousel with electron dot hover - Dark tech design tokens Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+498
-345
@@ -1,242 +1,139 @@
|
|||||||
import { useRef, useMemo, useEffect, useState, useCallback } from 'react';
|
import { useRef, useMemo, useEffect, useState } from 'react';
|
||||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||||
import { Text } from '@react-three/drei';
|
import { EffectComposer, Bloom, ChromaticAberration, Scanline } from '@react-three/postprocessing';
|
||||||
import { EffectComposer, Bloom, ChromaticAberration, Scanline, Glitch as GlitchEffect } from '@react-three/postprocessing';
|
import { BlendFunction } from 'postprocessing';
|
||||||
import { GlitchMode, BlendFunction } from 'postprocessing';
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
/* ===================================================================
|
/* ===================================================================
|
||||||
WEBGL BACKGROUND — Full-page immersive dark tech layer
|
PCB JOURNEY — Scroll follows copper traces on a circuit board
|
||||||
|
|
||||||
Sections (scroll-driven):
|
The camera travels along PCB traces as the user scrolls.
|
||||||
- HERO (0-100vh): Full atom + particles + text rings
|
Current flows through the traces as glowing particles.
|
||||||
- ABOUT (100-200vh): Circuit traces + node grid
|
Site sections are "components" soldered onto the board.
|
||||||
- CASES (200-300vh): Floating card planes
|
|
||||||
- SPRINTS (300-400vh): Orbital rings
|
|
||||||
- CONTACT (400vh+): Atom returns, particles intensify
|
|
||||||
=================================================================== */
|
=================================================================== */
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
cyan: new THREE.Color('#5bd1d8'),
|
copper: new THREE.Color('#c87533'),
|
||||||
amber: new THREE.Color('#f1c27a'),
|
copperBright: new THREE.Color('#e8a040'),
|
||||||
green: new THREE.Color('#b6d18f'),
|
substrate: new THREE.Color('#1a3a1a'),
|
||||||
electric: new THREE.Color('#0071e3'),
|
substrateDark: new THREE.Color('#0d1f0d'),
|
||||||
nucleus: new THREE.Color('#ff6b35'),
|
solder: new THREE.Color('#c0c0c0'),
|
||||||
white: new THREE.Color('#ffffff'),
|
current: new THREE.Color('#5bd1d8'),
|
||||||
|
currentWarm: new THREE.Color('#f1c27a'),
|
||||||
|
silkscreen: new THREE.Color('#e8e8d0'),
|
||||||
|
via: new THREE.Color('#888888'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const FONT_URL = '/assets/fonts/manrope-regular.ttf';
|
/* ---------- Scroll state ---------- */
|
||||||
|
let scrollProgress = 0;
|
||||||
|
|
||||||
/* ---------- Shared scroll state ---------- */
|
/* ---------- PCB Trace path (the main route the camera follows) ---------- */
|
||||||
let globalScroll = 0;
|
const TRACE_POINTS = [
|
||||||
let globalMaxScroll = 1;
|
// Start — top of board
|
||||||
|
new THREE.Vector3(0, 0, 0),
|
||||||
|
new THREE.Vector3(2, 0, -4),
|
||||||
|
new THREE.Vector3(5, 0, -5),
|
||||||
|
// First bend (Hero section)
|
||||||
|
new THREE.Vector3(8, 0, -5),
|
||||||
|
new THREE.Vector3(8, 0, -8),
|
||||||
|
// Horizontal run (About)
|
||||||
|
new THREE.Vector3(5, 0, -12),
|
||||||
|
new THREE.Vector3(0, 0, -14),
|
||||||
|
// Via + layer change (Cases)
|
||||||
|
new THREE.Vector3(-3, -0.1, -16),
|
||||||
|
new THREE.Vector3(-6, -0.1, -18),
|
||||||
|
// Right angle bend (Photos)
|
||||||
|
new THREE.Vector3(-6, -0.1, -22),
|
||||||
|
new THREE.Vector3(-3, 0, -24),
|
||||||
|
// Long straight (Sprints)
|
||||||
|
new THREE.Vector3(2, 0, -26),
|
||||||
|
new THREE.Vector3(6, 0, -28),
|
||||||
|
// Final destination (Contact)
|
||||||
|
new THREE.Vector3(6, 0, -32),
|
||||||
|
new THREE.Vector3(3, 0, -35),
|
||||||
|
new THREE.Vector3(0, 0, -38),
|
||||||
|
];
|
||||||
|
|
||||||
/* ---------- Particle system (persistent across sections) ---------- */
|
function createTraceCurve() {
|
||||||
function ParticleField({ count = 800 }) {
|
return new THREE.CatmullRomCurve3(TRACE_POINTS, false, 'catmullrom', 0.3);
|
||||||
const ref = useRef<THREE.Points>(null);
|
|
||||||
|
|
||||||
const { positions, velocities, colors, sizes } = useMemo(() => {
|
|
||||||
const pos = new Float32Array(count * 3);
|
|
||||||
const vel = new Float32Array(count * 3);
|
|
||||||
const col = new Float32Array(count * 3);
|
|
||||||
const sz = new Float32Array(count);
|
|
||||||
const palette = [COLORS.cyan, COLORS.amber, COLORS.green, COLORS.electric];
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
// Spread across a tall vertical volume
|
|
||||||
pos[i * 3] = (Math.random() - 0.5) * 20;
|
|
||||||
pos[i * 3 + 1] = (Math.random() - 0.5) * 60;
|
|
||||||
pos[i * 3 + 2] = (Math.random() - 0.5) * 15;
|
|
||||||
|
|
||||||
vel[i * 3] = (Math.random() - 0.5) * 0.003;
|
|
||||||
vel[i * 3 + 1] = (Math.random() - 0.5) * 0.002;
|
|
||||||
vel[i * 3 + 2] = (Math.random() - 0.5) * 0.003;
|
|
||||||
|
|
||||||
const c = palette[Math.floor(Math.random() * palette.length)];
|
|
||||||
col[i * 3] = c.r;
|
|
||||||
col[i * 3 + 1] = c.g;
|
|
||||||
col[i * 3 + 2] = c.b;
|
|
||||||
|
|
||||||
sz[i] = 0.02 + Math.random() * 0.04;
|
|
||||||
}
|
|
||||||
return { positions: pos, velocities: vel, colors: col, sizes: sz };
|
|
||||||
}, [count]);
|
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
const posAttr = ref.current.geometry.attributes.position as THREE.BufferAttribute;
|
|
||||||
const arr = posAttr.array as Float32Array;
|
|
||||||
const t = clock.getElapsedTime();
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
arr[i * 3] += velocities[i * 3] + Math.sin(t * 0.3 + i * 0.1) * 0.001;
|
|
||||||
arr[i * 3 + 1] += velocities[i * 3 + 1];
|
|
||||||
arr[i * 3 + 2] += velocities[i * 3 + 2];
|
|
||||||
|
|
||||||
// Wrap around
|
|
||||||
if (Math.abs(arr[i * 3]) > 12) arr[i * 3] *= -0.5;
|
|
||||||
if (Math.abs(arr[i * 3 + 1]) > 35) arr[i * 3 + 1] *= -0.5;
|
|
||||||
if (Math.abs(arr[i * 3 + 2]) > 8) arr[i * 3 + 2] *= -0.5;
|
|
||||||
}
|
|
||||||
posAttr.needsUpdate = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<points ref={ref}>
|
|
||||||
<bufferGeometry>
|
|
||||||
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
|
|
||||||
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
|
|
||||||
</bufferGeometry>
|
|
||||||
<pointsMaterial
|
|
||||||
size={0.05}
|
|
||||||
vertexColors
|
|
||||||
transparent
|
|
||||||
opacity={0.35}
|
|
||||||
sizeAttenuation
|
|
||||||
depthWrite={false}
|
|
||||||
blending={THREE.AdditiveBlending}
|
|
||||||
/>
|
|
||||||
</points>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Nucleus (Hero center) ---------- */
|
/* ---------- Main copper trace ---------- */
|
||||||
function Nucleus() {
|
function CopperTrace() {
|
||||||
const coreRef = useRef<THREE.Mesh>(null);
|
const curve = useMemo(() => createTraceCurve(), []);
|
||||||
const glowRef = useRef<THREE.Mesh>(null);
|
const tubeGeo = useMemo(() => {
|
||||||
const shellRef = useRef<THREE.Mesh>(null);
|
return new THREE.TubeGeometry(curve, 200, 0.08, 8, false);
|
||||||
|
}, [curve]);
|
||||||
useFrame(({ clock, pointer }) => {
|
|
||||||
const t = clock.getElapsedTime();
|
|
||||||
const scrollFade = 1; // atome fixe, toujours visible
|
|
||||||
const mouseProx = Math.max(0, 1 - Math.sqrt(pointer.x ** 2 + pointer.y ** 2));
|
|
||||||
|
|
||||||
if (coreRef.current) {
|
|
||||||
const s = (1 + Math.sin(t * 3) * 0.08 + mouseProx * 0.15) * scrollFade;
|
|
||||||
coreRef.current.scale.setScalar(Math.max(0.01, s));
|
|
||||||
(coreRef.current.material as THREE.MeshStandardMaterial).emissiveIntensity = 0.8 + mouseProx * 1;
|
|
||||||
}
|
|
||||||
if (glowRef.current) {
|
|
||||||
const s = (1 + Math.sin(t * 2) * 0.15 + mouseProx * 0.3) * scrollFade;
|
|
||||||
glowRef.current.scale.setScalar(Math.max(0.01, s));
|
|
||||||
(glowRef.current.material as THREE.MeshStandardMaterial).opacity = (0.15 + mouseProx * 0.2) * scrollFade;
|
|
||||||
}
|
|
||||||
if (shellRef.current) {
|
|
||||||
shellRef.current.rotation.y = t * (0.5 + mouseProx * 2);
|
|
||||||
shellRef.current.rotation.x = t * 0.3;
|
|
||||||
shellRef.current.scale.setScalar(Math.max(0.01, scrollFade));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<mesh ref={shellRef}>
|
{/* Main trace — copper tube */}
|
||||||
<icosahedronGeometry args={[0.9, 1]} />
|
<mesh geometry={tubeGeo}>
|
||||||
<meshStandardMaterial color={COLORS.electric} wireframe transparent opacity={0.08} emissive={COLORS.electric} emissiveIntensity={0.5} />
|
<meshStandardMaterial
|
||||||
|
color={COLORS.copper}
|
||||||
|
emissive={COLORS.copper}
|
||||||
|
emissiveIntensity={0.3}
|
||||||
|
metalness={0.8}
|
||||||
|
roughness={0.3}
|
||||||
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh ref={glowRef}>
|
{/* Glow around trace */}
|
||||||
<sphereGeometry args={[0.7, 32, 32]} />
|
<mesh geometry={tubeGeo}>
|
||||||
<meshStandardMaterial color={COLORS.nucleus} transparent opacity={0.15} emissive={COLORS.nucleus} emissiveIntensity={1.2} />
|
<meshStandardMaterial
|
||||||
</mesh>
|
color={COLORS.copperBright}
|
||||||
<mesh ref={coreRef}>
|
transparent
|
||||||
<sphereGeometry args={[0.32, 32, 32]} />
|
opacity={0.08}
|
||||||
<meshStandardMaterial color="#ffffff" emissive={COLORS.amber} emissiveIntensity={2} metalness={0.6} roughness={0.2} />
|
emissive={COLORS.copperBright}
|
||||||
|
emissiveIntensity={0.5}
|
||||||
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Orbit ring + electron trail ---------- */
|
/* ---------- Secondary traces (decorative, branch off main) ---------- */
|
||||||
function Orbit({ tilt, speed, color, trailCount = 25 }: {
|
function SecondaryTraces() {
|
||||||
tilt: number[]; speed: number; color: THREE.Color; trailCount?: number;
|
|
||||||
}) {
|
|
||||||
const meshRef = useRef<THREE.InstancedMesh>(null);
|
|
||||||
const ringRef = useRef<THREE.Group>(null);
|
|
||||||
const phase = useMemo(() => Math.random() * Math.PI * 2, []);
|
|
||||||
const dummy = useMemo(() => new THREE.Object3D(), []);
|
|
||||||
const R = 3.2;
|
|
||||||
const E = 0.38;
|
|
||||||
|
|
||||||
const ringGeo = useMemo(() => {
|
|
||||||
const pts: THREE.Vector3[] = [];
|
|
||||||
for (let i = 0; i <= 128; i++) {
|
|
||||||
const a = (i / 128) * Math.PI * 2;
|
|
||||||
pts.push(new THREE.Vector3(Math.cos(a) * R, Math.sin(a) * R * E, 0));
|
|
||||||
}
|
|
||||||
return new THREE.BufferGeometry().setFromPoints(pts);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
|
||||||
if (!meshRef.current || !ringRef.current) return;
|
|
||||||
const t = clock.getElapsedTime() * speed + phase;
|
|
||||||
const scrollFade = 1; // orbites fixes
|
|
||||||
ringRef.current.scale.setScalar(Math.max(0.01, scrollFade));
|
|
||||||
|
|
||||||
for (let i = 0; i < trailCount; i++) {
|
|
||||||
const age = i / trailCount;
|
|
||||||
const angle = t - age * 0.8;
|
|
||||||
dummy.position.set(Math.cos(angle) * R, Math.sin(angle) * R * E, 0);
|
|
||||||
dummy.scale.setScalar(Math.max(0.001, ((1 - age) * 0.14 + 0.02)));
|
|
||||||
dummy.updateMatrix();
|
|
||||||
meshRef.current.setMatrixAt(i, dummy.matrix);
|
|
||||||
}
|
|
||||||
meshRef.current.instanceMatrix.needsUpdate = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group ref={ringRef} rotation={tilt as [number, number, number]}>
|
|
||||||
<line geometry={ringGeo}>
|
|
||||||
<lineBasicMaterial color={color} transparent opacity={0.12} />
|
|
||||||
</line>
|
|
||||||
<instancedMesh ref={meshRef} args={[undefined, undefined, trailCount]}>
|
|
||||||
<sphereGeometry args={[1, 8, 8]} />
|
|
||||||
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={2} transparent opacity={0.9} />
|
|
||||||
</instancedMesh>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Circuit traces (About section) ---------- */
|
|
||||||
function CircuitTraces() {
|
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
|
||||||
const traces = useMemo(() => {
|
const traces = useMemo(() => {
|
||||||
const lines: { points: THREE.Vector3[]; color: THREE.Color }[] = [];
|
const paths: THREE.Vector3[][] = [];
|
||||||
for (let i = 0; i < 30; i++) {
|
// Branch traces at various points along the main route
|
||||||
const pts: THREE.Vector3[] = [];
|
const branches = [
|
||||||
let x = (Math.random() - 0.5) * 16;
|
// Hero area branches
|
||||||
let y = -15 + (Math.random() - 0.5) * 10;
|
[new THREE.Vector3(3, 0, -4), new THREE.Vector3(3, 0, -2), new THREE.Vector3(5, 0, -1)],
|
||||||
pts.push(new THREE.Vector3(x, y, (Math.random() - 0.5) * 4));
|
[new THREE.Vector3(5, 0, -5), new THREE.Vector3(7, 0, -3), new THREE.Vector3(10, 0, -3)],
|
||||||
for (let j = 0; j < 4 + Math.floor(Math.random() * 4); j++) {
|
// About area
|
||||||
// PCB-style: horizontal or vertical segments
|
[new THREE.Vector3(2, 0, -12), new THREE.Vector3(2, 0, -10), new THREE.Vector3(4, 0, -9)],
|
||||||
if (Math.random() > 0.5) x += (Math.random() - 0.5) * 3;
|
[new THREE.Vector3(-1, 0, -13), new THREE.Vector3(-3, 0, -11), new THREE.Vector3(-5, 0, -11)],
|
||||||
else y += (Math.random() - 0.5) * 2;
|
// Cases area
|
||||||
pts.push(new THREE.Vector3(x, y, pts[0].z));
|
[new THREE.Vector3(-4, -0.1, -17), new THREE.Vector3(-2, -0.1, -15), new THREE.Vector3(0, -0.1, -15)],
|
||||||
}
|
[new THREE.Vector3(-6, -0.1, -20), new THREE.Vector3(-8, -0.1, -19), new THREE.Vector3(-10, -0.1, -18)],
|
||||||
lines.push({
|
// Sprints area
|
||||||
points: pts,
|
[new THREE.Vector3(4, 0, -27), new THREE.Vector3(4, 0, -25), new THREE.Vector3(6, 0, -24)],
|
||||||
color: [COLORS.cyan, COLORS.green, COLORS.electric][Math.floor(Math.random() * 3)],
|
[new THREE.Vector3(1, 0, -27), new THREE.Vector3(-1, 0, -25), new THREE.Vector3(-3, 0, -25)],
|
||||||
});
|
// Contact area
|
||||||
}
|
[new THREE.Vector3(4, 0, -33), new THREE.Vector3(6, 0, -31), new THREE.Vector3(8, 0, -31)],
|
||||||
return lines;
|
[new THREE.Vector3(1, 0, -36), new THREE.Vector3(-1, 0, -34), new THREE.Vector3(-3, 0, -34)],
|
||||||
|
];
|
||||||
|
|
||||||
|
branches.forEach(pts => {
|
||||||
|
const curve = new THREE.CatmullRomCurve3(pts, false, 'catmullrom', 0.5);
|
||||||
|
const tubePts = curve.getPoints(30);
|
||||||
|
paths.push(tubePts);
|
||||||
|
});
|
||||||
|
return paths;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
|
||||||
if (!groupRef.current) return;
|
|
||||||
const scrollPos = globalScroll;
|
|
||||||
// Visible between 20-50% scroll
|
|
||||||
const fade = Math.max(0, Math.min(1, (scrollPos - 0.15) * 5)) * Math.max(0, Math.min(1, (0.45 - scrollPos) * 5));
|
|
||||||
groupRef.current.children.forEach((child, i) => {
|
|
||||||
(child as THREE.Line).material.opacity = fade * 0.3;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={groupRef}>
|
<group>
|
||||||
{traces.map((trace, i) => {
|
{traces.map((pts, i) => {
|
||||||
const geo = new THREE.BufferGeometry().setFromPoints(trace.points);
|
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
||||||
return (
|
return (
|
||||||
<line key={i} geometry={geo}>
|
<line key={i} geometry={geo}>
|
||||||
<lineBasicMaterial color={trace.color} transparent opacity={0.3} blending={THREE.AdditiveBlending} />
|
<lineBasicMaterial
|
||||||
|
color={COLORS.copper}
|
||||||
|
transparent
|
||||||
|
opacity={0.25}
|
||||||
|
linewidth={1}
|
||||||
|
/>
|
||||||
</line>
|
</line>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -244,35 +141,34 @@ function CircuitTraces() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Circuit nodes (solder points) ---------- */
|
/* ---------- Current flow particles along the trace ---------- */
|
||||||
function CircuitNodes() {
|
function CurrentFlow({ count = 60 }) {
|
||||||
const ref = useRef<THREE.InstancedMesh>(null);
|
const ref = useRef<THREE.InstancedMesh>(null);
|
||||||
const dummy = useMemo(() => new THREE.Object3D(), []);
|
const dummy = useMemo(() => new THREE.Object3D(), []);
|
||||||
const count = 40;
|
const curve = useMemo(() => createTraceCurve(), []);
|
||||||
|
const phases = useMemo(() => Array.from({ length: count }, () => Math.random()), [count]);
|
||||||
const nodePositions = useMemo(() => {
|
|
||||||
const pos: [number, number, number][] = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
pos.push([
|
|
||||||
(Math.random() - 0.5) * 16,
|
|
||||||
-15 + (Math.random() - 0.5) * 10,
|
|
||||||
(Math.random() - 0.5) * 4,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return pos;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
useFrame(({ clock }) => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
const t = clock.getElapsedTime();
|
const t = clock.getElapsedTime();
|
||||||
const scrollPos = globalScroll;
|
|
||||||
const fade = Math.max(0, Math.min(1, (scrollPos - 0.15) * 5)) * Math.max(0, Math.min(1, (0.45 - scrollPos) * 5));
|
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const [x, y, z] = nodePositions[i];
|
// Each particle flows along the curve
|
||||||
dummy.position.set(x, y, z);
|
const progress = ((phases[i] + t * 0.06) % 1);
|
||||||
const pulse = 1 + Math.sin(t * 2 + i) * 0.3;
|
const pos = curve.getPointAt(progress);
|
||||||
dummy.scale.setScalar(0.06 * pulse * fade);
|
|
||||||
|
// Add slight jitter
|
||||||
|
dummy.position.set(
|
||||||
|
pos.x + (Math.random() - 0.5) * 0.05,
|
||||||
|
pos.y + 0.02 + Math.sin(t * 3 + i) * 0.02,
|
||||||
|
pos.z + (Math.random() - 0.5) * 0.05,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Size based on proximity to camera/scroll position
|
||||||
|
const distToScroll = Math.abs(progress - scrollProgress);
|
||||||
|
const brightness = Math.max(0.2, 1 - distToScroll * 3);
|
||||||
|
dummy.scale.setScalar(0.03 * brightness + 0.01);
|
||||||
|
|
||||||
dummy.updateMatrix();
|
dummy.updateMatrix();
|
||||||
ref.current.setMatrixAt(i, dummy.matrix);
|
ref.current.setMatrixAt(i, dummy.matrix);
|
||||||
}
|
}
|
||||||
@@ -281,160 +177,419 @@ function CircuitNodes() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<instancedMesh ref={ref} args={[undefined, undefined, count]}>
|
<instancedMesh ref={ref} args={[undefined, undefined, count]}>
|
||||||
<sphereGeometry args={[1, 8, 8]} />
|
<sphereGeometry args={[1, 6, 6]} />
|
||||||
<meshStandardMaterial color={COLORS.cyan} emissive={COLORS.cyan} emissiveIntensity={2} transparent />
|
<meshStandardMaterial
|
||||||
|
color={COLORS.current}
|
||||||
|
emissive={COLORS.current}
|
||||||
|
emissiveIntensity={3}
|
||||||
|
transparent
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
</instancedMesh>
|
</instancedMesh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Sprint orbital rings ---------- */
|
/* ---------- Warm current (second layer) ---------- */
|
||||||
function SprintRings() {
|
function WarmCurrentFlow({ count = 30 }) {
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const ref = useRef<THREE.InstancedMesh>(null);
|
||||||
const rings = [
|
const dummy = useMemo(() => new THREE.Object3D(), []);
|
||||||
{ radius: 2.5, color: COLORS.cyan, label: '28%', speed: 0.3 },
|
const curve = useMemo(() => createTraceCurve(), []);
|
||||||
{ radius: 3.5, color: COLORS.amber, label: '55%', speed: 0.2 },
|
const phases = useMemo(() => Array.from({ length: count }, () => Math.random()), [count]);
|
||||||
{ radius: 4.5, color: COLORS.green, label: '100%', speed: 0.15 },
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const progress = ((phases[i] + t * 0.04 + 0.5) % 1);
|
||||||
|
const pos = curve.getPointAt(progress);
|
||||||
|
dummy.position.set(pos.x, pos.y + 0.03, pos.z);
|
||||||
|
const distToScroll = Math.abs(progress - scrollProgress);
|
||||||
|
const brightness = Math.max(0.1, 1 - distToScroll * 4);
|
||||||
|
dummy.scale.setScalar(0.02 * brightness + 0.005);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
ref.current.setMatrixAt(i, dummy.matrix);
|
||||||
|
}
|
||||||
|
ref.current.instanceMatrix.needsUpdate = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<instancedMesh ref={ref} args={[undefined, undefined, count]}>
|
||||||
|
<sphereGeometry args={[1, 6, 6]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={COLORS.currentWarm}
|
||||||
|
emissive={COLORS.currentWarm}
|
||||||
|
emissiveIntensity={2}
|
||||||
|
transparent
|
||||||
|
opacity={0.7}
|
||||||
|
/>
|
||||||
|
</instancedMesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- PCB Substrate (green board) ---------- */
|
||||||
|
function Substrate() {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Main board */}
|
||||||
|
<mesh position={[0, -0.15, -19]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<planeGeometry args={[30, 50]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={COLORS.substrateDark}
|
||||||
|
roughness={0.9}
|
||||||
|
metalness={0.05}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* Solder mask grid pattern */}
|
||||||
|
{Array.from({ length: 20 }).map((_, i) => (
|
||||||
|
<mesh key={`h${i}`} position={[0, -0.14, -2 - i * 2]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<planeGeometry args={[28, 0.01]} />
|
||||||
|
<meshBasicMaterial color={COLORS.substrate} transparent opacity={0.15} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: 14 }).map((_, i) => (
|
||||||
|
<mesh key={`v${i}`} position={[-13 + i * 2, -0.14, -19]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<planeGeometry args={[0.01, 46]} />
|
||||||
|
<meshBasicMaterial color={COLORS.substrate} transparent opacity={0.15} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Vias (through-hole connections) ---------- */
|
||||||
|
function Vias() {
|
||||||
|
const positions: [number, number, number][] = [
|
||||||
|
[8, 0, -6], [5, 0, -8], [2, 0, -11],
|
||||||
|
[-2, 0, -15], [-5, 0, -17], [-4, 0, -21],
|
||||||
|
[0, 0, -25], [4, 0, -29], [2, 0, -33],
|
||||||
|
// Extra decorative vias
|
||||||
|
[10, 0, -4], [-7, 0, -12], [7, 0, -22],
|
||||||
|
[-8, 0, -27], [8, 0, -35], [-2, 0, -38],
|
||||||
];
|
];
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
|
||||||
if (!groupRef.current) return;
|
|
||||||
const scrollPos = globalScroll;
|
|
||||||
const fade = Math.max(0, Math.min(1, (scrollPos - 0.5) * 5)) * Math.max(0, Math.min(1, (0.8 - scrollPos) * 5));
|
|
||||||
groupRef.current.position.y = -35;
|
|
||||||
groupRef.current.children.forEach(child => {
|
|
||||||
child.scale.setScalar(Math.max(0.01, fade));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={groupRef}>
|
<group>
|
||||||
{rings.map((ring, i) => {
|
{positions.map(([x, y, z], i) => (
|
||||||
const pts: THREE.Vector3[] = [];
|
<group key={i} position={[x, y, z]}>
|
||||||
for (let j = 0; j <= 128; j++) {
|
{/* Via ring */}
|
||||||
const a = (j / 128) * Math.PI * 2;
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
pts.push(new THREE.Vector3(Math.cos(a) * ring.radius, 0, Math.sin(a) * ring.radius));
|
<ringGeometry args={[0.12, 0.2, 16]} />
|
||||||
}
|
<meshStandardMaterial
|
||||||
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
color={COLORS.solder}
|
||||||
return (
|
metalness={0.9}
|
||||||
<group key={i} rotation={[0.5 + i * 0.3, 0, i * 0.2]}>
|
roughness={0.2}
|
||||||
<line geometry={geo}>
|
emissive={COLORS.via}
|
||||||
<lineBasicMaterial color={ring.color} transparent opacity={0.25} />
|
emissiveIntensity={0.2}
|
||||||
</line>
|
/>
|
||||||
</group>
|
</mesh>
|
||||||
);
|
{/* Via hole */}
|
||||||
})}
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<circleGeometry args={[0.1, 16]} />
|
||||||
|
<meshBasicMaterial color="#050505" />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Contact section atom (returns at bottom) ---------- */
|
/* ---------- 3D Electronic components on PCB ---------- */
|
||||||
function ContactAtom() {
|
|
||||||
|
/* IC / Microcontroller (black rectangle with pins) */
|
||||||
|
function ICComponent({ position, size = [2, 0.3, 1.2], label, color = '#1a1a1a' }: {
|
||||||
|
position: [number, number, number]; size?: [number, number, number]; label: string; color?: string;
|
||||||
|
}) {
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const pins = Math.floor(size[0] / 0.3);
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
useFrame(({ clock }) => {
|
||||||
if (!groupRef.current) return;
|
if (!groupRef.current) return;
|
||||||
const scrollPos = globalScroll;
|
const cameraZ = -scrollProgress * 38;
|
||||||
const t = clock.getElapsedTime();
|
const dist = Math.abs(position[2] - cameraZ);
|
||||||
const fade = Math.max(0, (scrollPos - 0.75) * 4);
|
const glow = Math.max(0, 1 - dist / 6);
|
||||||
groupRef.current.position.y = -55;
|
// Subtle float when active
|
||||||
groupRef.current.scale.setScalar(Math.max(0.01, fade * 0.6));
|
groupRef.current.position.y = position[1] + glow * Math.sin(clock.getElapsedTime() * 2) * 0.03;
|
||||||
groupRef.current.rotation.y = t * 0.2;
|
|
||||||
groupRef.current.rotation.x = Math.sin(t * 0.1) * 0.2;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={groupRef}>
|
<group ref={groupRef} position={position}>
|
||||||
<mesh>
|
{/* IC body */}
|
||||||
<icosahedronGeometry args={[2, 1]} />
|
<mesh position={[0, size[1] / 2, 0]}>
|
||||||
<meshStandardMaterial color={COLORS.electric} wireframe transparent opacity={0.15} emissive={COLORS.electric} emissiveIntensity={0.5} />
|
<boxGeometry args={size} />
|
||||||
|
<meshStandardMaterial color={color} roughness={0.6} metalness={0.1} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh>
|
{/* Dot (pin 1 marker) */}
|
||||||
<sphereGeometry args={[0.8, 32, 32]} />
|
<mesh position={[-size[0] / 2 + 0.15, size[1] + 0.01, -size[2] / 2 + 0.15]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<meshStandardMaterial color={COLORS.nucleus} transparent opacity={0.2} emissive={COLORS.nucleus} emissiveIntensity={1} />
|
<circleGeometry args={[0.05, 8]} />
|
||||||
|
<meshBasicMaterial color="#e0e0e0" />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh>
|
{/* Silkscreen label */}
|
||||||
<sphereGeometry args={[0.4, 32, 32]} />
|
<mesh position={[0, size[1] + 0.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<meshStandardMaterial color="#ffffff" emissive={COLORS.amber} emissiveIntensity={2} />
|
<planeGeometry args={[size[0] * 0.8, 0.15]} />
|
||||||
|
<meshBasicMaterial color={COLORS.silkscreen} transparent opacity={0.4} />
|
||||||
|
</mesh>
|
||||||
|
{/* Pins — both sides */}
|
||||||
|
{Array.from({ length: pins }).map((_, i) => (
|
||||||
|
<group key={`pins-${i}`}>
|
||||||
|
<mesh position={[-size[0] / 2 + 0.15 + i * 0.3, 0.05, -size[2] / 2 - 0.08]}>
|
||||||
|
<boxGeometry args={[0.06, 0.02, 0.15]} />
|
||||||
|
<meshStandardMaterial color={COLORS.solder} metalness={0.9} roughness={0.2} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-size[0] / 2 + 0.15 + i * 0.3, 0.05, size[2] / 2 + 0.08]}>
|
||||||
|
<boxGeometry args={[0.06, 0.02, 0.15]} />
|
||||||
|
<meshStandardMaterial color={COLORS.solder} metalness={0.9} roughness={0.2} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
{/* Solder pads glow */}
|
||||||
|
<mesh position={[0, -0.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<planeGeometry args={[size[0] + 0.5, size[2] + 0.5]} />
|
||||||
|
<meshStandardMaterial color={COLORS.copperBright} transparent opacity={0.04} emissive={COLORS.current} emissiveIntensity={0.2} side={THREE.DoubleSide} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Full scene ---------- */
|
/* Capacitor (cylinder) */
|
||||||
function Scene() {
|
function Capacitor({ position, radius = 0.25, height = 0.5, color = '#2a4a8a' }: {
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
position: [number, number, number]; radius?: number; height?: number; color?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
const orbits = useMemo(() => [
|
useFrame(({ clock }) => {
|
||||||
{ tilt: [-0.5, 0, 0], speed: 1.0, color: COLORS.cyan },
|
if (!ref.current) return;
|
||||||
{ tilt: [0.5, 0.3, 0], speed: 0.72, color: COLORS.amber },
|
const cameraZ = -scrollProgress * 38;
|
||||||
{ tilt: [1.57, 0.2, 0.4], speed: 0.55, color: COLORS.green },
|
const dist = Math.abs(position[2] - cameraZ);
|
||||||
], []);
|
const glow = Math.max(0, 1 - dist / 6);
|
||||||
|
(ref.current.material as THREE.MeshStandardMaterial).emissiveIntensity = glow * 0.5;
|
||||||
|
});
|
||||||
|
|
||||||
useFrame(({ clock, pointer }) => {
|
return (
|
||||||
if (!groupRef.current) return;
|
<group position={position}>
|
||||||
|
<mesh ref={ref} position={[0, height / 2, 0]}>
|
||||||
|
<cylinderGeometry args={[radius, radius, height, 16]} />
|
||||||
|
<meshStandardMaterial color={color} roughness={0.4} metalness={0.2} emissive={COLORS.current} emissiveIntensity={0} />
|
||||||
|
</mesh>
|
||||||
|
{/* Top marking */}
|
||||||
|
<mesh position={[0, height + 0.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<circleGeometry args={[radius * 0.7, 16]} />
|
||||||
|
<meshStandardMaterial color="#333" metalness={0.5} roughness={0.3} />
|
||||||
|
</mesh>
|
||||||
|
{/* Stripe */}
|
||||||
|
<mesh position={[0, height * 0.7, radius + 0.01]}>
|
||||||
|
<planeGeometry args={[radius * 1.2, height * 0.15]} />
|
||||||
|
<meshBasicMaterial color={COLORS.silkscreen} transparent opacity={0.3} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resistor (small rectangle with color bands) */
|
||||||
|
function Resistor({ position, rotation = [0, 0, 0] }: { position: [number, number, number]; rotation?: [number, number, number] }) {
|
||||||
|
return (
|
||||||
|
<group position={position} rotation={rotation}>
|
||||||
|
<mesh position={[0, 0.04, 0]}>
|
||||||
|
<boxGeometry args={[0.5, 0.08, 0.2]} />
|
||||||
|
<meshStandardMaterial color="#2a2a2a" roughness={0.7} />
|
||||||
|
</mesh>
|
||||||
|
{/* Solder pads */}
|
||||||
|
<mesh position={[-0.22, 0.02, 0]}>
|
||||||
|
<boxGeometry args={[0.1, 0.04, 0.22]} />
|
||||||
|
<meshStandardMaterial color={COLORS.solder} metalness={0.9} roughness={0.2} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.22, 0.02, 0]}>
|
||||||
|
<boxGeometry args={[0.1, 0.04, 0.22]} />
|
||||||
|
<meshStandardMaterial color={COLORS.solder} metalness={0.9} roughness={0.2} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LED (small dome with glow) */
|
||||||
|
function LED({ position, color = '#5bd1d8' }: { position: [number, number, number]; color?: string }) {
|
||||||
|
const ref = useRef<THREE.Mesh>(null);
|
||||||
|
const threeColor = useMemo(() => new THREE.Color(color), [color]);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!ref.current) return;
|
||||||
const t = clock.getElapsedTime();
|
const t = clock.getElapsedTime();
|
||||||
const mouseDist = Math.sqrt(pointer.x ** 2 + pointer.y ** 2);
|
const cameraZ = -scrollProgress * 38;
|
||||||
const speedMul = 1 + mouseDist * 0.5;
|
const dist = Math.abs(position[2] - cameraZ);
|
||||||
|
const proximity = Math.max(0, 1 - dist / 5);
|
||||||
|
(ref.current.material as THREE.MeshStandardMaterial).emissiveIntensity = proximity * (1.5 + Math.sin(t * 3) * 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
// Only the hero atom group rotates with mouse
|
return (
|
||||||
const heroGroup = groupRef.current.children[0] as THREE.Group;
|
<group position={position}>
|
||||||
if (heroGroup) {
|
<mesh ref={ref}>
|
||||||
heroGroup.rotation.y = t * 0.12 * speedMul + pointer.x * 0.4;
|
<sphereGeometry args={[0.08, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2]} />
|
||||||
heroGroup.rotation.x = -0.2 + pointer.y * 0.3;
|
<meshStandardMaterial color={color} emissive={threeColor} emissiveIntensity={0} transparent opacity={0.9} />
|
||||||
|
</mesh>
|
||||||
|
{/* LED pad */}
|
||||||
|
<mesh position={[0, -0.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<circleGeometry args={[0.12, 8]} />
|
||||||
|
<meshStandardMaterial color={COLORS.solder} metalness={0.8} roughness={0.3} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All components assembled on the PCB */
|
||||||
|
function PCBComponents() {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* HERO — Main MCU (ESP32 style) */}
|
||||||
|
<ICComponent position={[6, 0, -6]} size={[2.5, 0.35, 1.5]} label="U1 — ESP32" />
|
||||||
|
<LED position={[7.5, 0, -5]} color="#5bd1d8" />
|
||||||
|
<LED position={[7.5, 0, -5.5]} color="#30d158" />
|
||||||
|
<Resistor position={[4.5, 0, -5.5]} />
|
||||||
|
<Resistor position={[4.5, 0, -6.3]} />
|
||||||
|
|
||||||
|
{/* ABOUT — Op-amp + passives */}
|
||||||
|
<ICComponent position={[1, 0, -13]} size={[1.8, 0.25, 0.8]} label="U2 — LM358" color="#222" />
|
||||||
|
<Capacitor position={[3, 0, -12.5]} radius={0.3} height={0.6} color="#8B4513" />
|
||||||
|
<Capacitor position={[-1, 0, -13.5]} radius={0.2} height={0.4} color="#2a4a8a" />
|
||||||
|
<Resistor position={[2, 0, -14]} rotation={[0, 0.5, 0]} />
|
||||||
|
<Resistor position={[0, 0, -12]} rotation={[0, -0.3, 0]} />
|
||||||
|
<LED position={[2.5, 0, -13]} color="#f1c27a" />
|
||||||
|
|
||||||
|
{/* CASES — Power stage (MOSFET + caps) */}
|
||||||
|
<ICComponent position={[-5, -0.1, -19]} size={[1.5, 0.4, 1]} label="Q1 — IRFZ44" color="#1a1a2a" />
|
||||||
|
<Capacitor position={[-3, -0.1, -18]} radius={0.4} height={0.8} color="#1a3a1a" />
|
||||||
|
<Capacitor position={[-7, -0.1, -20]} radius={0.35} height={0.7} color="#8B4513" />
|
||||||
|
<Resistor position={[-4, -0.1, -20]} />
|
||||||
|
<LED position={[-6.5, -0.1, -18.5]} color="#ff6b6b" />
|
||||||
|
|
||||||
|
{/* MEDIA area — Connector */}
|
||||||
|
<ICComponent position={[-2, 0, -23]} size={[2, 0.5, 0.8]} label="J1 — USB-C" color="#333" />
|
||||||
|
<LED position={[-0.5, 0, -23]} color="#5bd1d8" />
|
||||||
|
<LED position={[-3.5, 0, -23]} color="#30d158" />
|
||||||
|
|
||||||
|
{/* SPRINTS — Voltage regulator */}
|
||||||
|
<ICComponent position={[4, 0, -27]} size={[1.2, 0.3, 0.6]} label="U3 — LM7805" color="#1a1a1a" />
|
||||||
|
<Capacitor position={[2.5, 0, -26.5]} radius={0.25} height={0.5} color="#2a4a8a" />
|
||||||
|
<Capacitor position={[5.5, 0, -27.5]} radius={0.25} height={0.5} color="#2a4a8a" />
|
||||||
|
<Resistor position={[3, 0, -28]} rotation={[0, 0.8, 0]} />
|
||||||
|
<LED position={[5, 0, -26.5]} color="#b6d18f" />
|
||||||
|
|
||||||
|
{/* CONTACT — Big capacitor (energy storage) + indicator */}
|
||||||
|
<Capacitor position={[1, 0, -36]} radius={0.5} height={1} color="#4a2a8a" />
|
||||||
|
<Capacitor position={[-1, 0, -37]} radius={0.4} height={0.8} color="#8B4513" />
|
||||||
|
<LED position={[2.5, 0, -36]} color="#ff6b35" />
|
||||||
|
<LED position={[-0.5, 0, -36]} color="#5bd1d8" />
|
||||||
|
<Resistor position={[0, 0, -35]} rotation={[0, 1.2, 0]} />
|
||||||
|
|
||||||
|
{/* Extra scattered passives for realism */}
|
||||||
|
<Resistor position={[9, 0, -3]} rotation={[0, 0.7, 0]} />
|
||||||
|
<Resistor position={[7, 0, -10]} rotation={[0, -0.4, 0]} />
|
||||||
|
<Resistor position={[-3, 0, -10]} rotation={[0, 1.1, 0]} />
|
||||||
|
<Resistor position={[-8, -0.1, -16]} />
|
||||||
|
<Resistor position={[1, 0, -30]} rotation={[0, 0.3, 0]} />
|
||||||
|
<Capacitor position={[10, 0, -7]} radius={0.15} height={0.3} />
|
||||||
|
<Capacitor position={[-4, 0, -25]} radius={0.15} height={0.3} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Ambient floating particles (dust/solder flux) ---------- */
|
||||||
|
function AmbientDust({ count = 200 }) {
|
||||||
|
const ref = useRef<THREE.Points>(null);
|
||||||
|
|
||||||
|
const positions = useMemo(() => {
|
||||||
|
const pos = new Float32Array(count * 3);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
pos[i * 3] = (Math.random() - 0.5) * 25;
|
||||||
|
pos[i * 3 + 1] = Math.random() * 3;
|
||||||
|
pos[i * 3 + 2] = Math.random() * -42;
|
||||||
}
|
}
|
||||||
|
return pos;
|
||||||
|
}, [count]);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const arr = (ref.current.geometry.attributes.position as THREE.BufferAttribute).array as Float32Array;
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
arr[i * 3 + 1] += Math.sin(t * 0.5 + i) * 0.001;
|
||||||
|
}
|
||||||
|
ref.current.geometry.attributes.position.needsUpdate = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={groupRef}>
|
<points ref={ref}>
|
||||||
{/* Hero atom group */}
|
<bufferGeometry>
|
||||||
<group>
|
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
|
||||||
<Nucleus />
|
</bufferGeometry>
|
||||||
{orbits.map((o, i) => (
|
<pointsMaterial
|
||||||
<Orbit key={i} tilt={o.tilt} speed={o.speed} color={o.color} />
|
size={0.03}
|
||||||
))}
|
color={COLORS.current}
|
||||||
</group>
|
transparent
|
||||||
|
opacity={0.2}
|
||||||
{/* Persistent particles */}
|
sizeAttenuation
|
||||||
<ParticleField />
|
depthWrite={false}
|
||||||
</group>
|
blending={THREE.AdditiveBlending}
|
||||||
|
/>
|
||||||
|
</points>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Scroll-reactive camera ---------- */
|
/* ---------- Camera that follows the trace path on scroll ---------- */
|
||||||
function ScrollCamera() {
|
function TraceCamera() {
|
||||||
const { camera } = useThree();
|
const { camera } = useThree();
|
||||||
const smooth = useRef({ x: 0, y: 0, scroll: 0 });
|
const curve = useMemo(() => createTraceCurve(), []);
|
||||||
|
const smooth = useRef({ progress: 0, mx: 0, my: 0 });
|
||||||
|
|
||||||
useFrame(({ pointer }) => {
|
useFrame(({ pointer }) => {
|
||||||
// Smooth interpolation
|
// Smooth scroll interpolation
|
||||||
smooth.current.x += (pointer.x - smooth.current.x) * 0.05;
|
smooth.current.progress += (scrollProgress - smooth.current.progress) * 0.06;
|
||||||
smooth.current.y += (pointer.y - smooth.current.y) * 0.05;
|
smooth.current.mx += (pointer.x - smooth.current.mx) * 0.04;
|
||||||
smooth.current.scroll += (globalScroll - smooth.current.scroll) * 0.08;
|
smooth.current.my += (pointer.y - smooth.current.my) * 0.04;
|
||||||
|
|
||||||
const s = smooth.current.scroll;
|
const p = Math.min(0.98, smooth.current.progress);
|
||||||
|
const pos = curve.getPointAt(p);
|
||||||
|
const lookAt = curve.getPointAt(Math.min(0.99, p + 0.03));
|
||||||
|
|
||||||
// Camera FIXED — slight parallax from mouse only, no scroll movement
|
// Camera slightly above and offset by mouse
|
||||||
camera.position.x = smooth.current.x * 1.2;
|
camera.position.set(
|
||||||
camera.position.y = smooth.current.y * 0.6;
|
pos.x + smooth.current.mx * 1.5,
|
||||||
camera.position.z = 14;
|
pos.y + 2.5 + smooth.current.my * 0.5,
|
||||||
|
pos.z + 1.5,
|
||||||
|
);
|
||||||
|
|
||||||
camera.lookAt(
|
camera.lookAt(
|
||||||
smooth.current.x * 0.2,
|
lookAt.x + smooth.current.mx * 0.3,
|
||||||
smooth.current.y * 0.1,
|
lookAt.y + 0.2,
|
||||||
0
|
lookAt.z,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Full scene ---------- */
|
||||||
|
function PCBScene() {
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<Substrate />
|
||||||
|
<CopperTrace />
|
||||||
|
<SecondaryTraces />
|
||||||
|
<Vias />
|
||||||
|
<PCBComponents />
|
||||||
|
<CurrentFlow />
|
||||||
|
<WarmCurrentFlow />
|
||||||
|
<AmbientDust />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Exported component ---------- */
|
/* ---------- Exported component ---------- */
|
||||||
export default function WebGLBackground() {
|
export default function WebGLBackground() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
const el = document.documentElement;
|
const el = document.documentElement;
|
||||||
globalMaxScroll = Math.max(1, el.scrollHeight - el.clientHeight);
|
const max = Math.max(1, el.scrollHeight - el.clientHeight);
|
||||||
globalScroll = el.scrollTop / globalMaxScroll;
|
scrollProgress = el.scrollTop / max;
|
||||||
};
|
};
|
||||||
window.addEventListener('scroll', onScroll, { passive: true });
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
onScroll();
|
onScroll();
|
||||||
@@ -454,28 +609,26 @@ export default function WebGLBackground() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [0, 0, 9], fov: 45 }}
|
camera={{ position: [0, 3, 2], fov: 50 }}
|
||||||
dpr={[1, 1.5]}
|
dpr={[1, 1.5]}
|
||||||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
|
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
|
||||||
style={{ pointerEvents: 'auto' }}
|
style={{ pointerEvents: 'auto' }}
|
||||||
>
|
>
|
||||||
<color attach="background" args={['#080808']} />
|
<color attach="background" args={['#060a06']} />
|
||||||
<fog attach="fog" args={['#080808', 6, 22]} />
|
<fog attach="fog" args={['#060a06', 4, 18]} />
|
||||||
|
|
||||||
<ambientLight intensity={0.06} />
|
<ambientLight intensity={0.08} />
|
||||||
<pointLight position={[5, 3, 5]} intensity={0.3} color="#ffffff" />
|
<pointLight position={[0, 5, 0]} intensity={0.3} color="#ffffff" distance={20} />
|
||||||
<pointLight position={[-4, -2, 3]} intensity={0.15} color="#5bd1d8" />
|
<directionalLight position={[5, 8, -10]} intensity={0.15} color="#5bd1d8" />
|
||||||
<pointLight position={[0, -20, -3]} intensity={0.1} color="#f1c27a" />
|
<directionalLight position={[-5, 6, -25]} intensity={0.1} color="#f1c27a" />
|
||||||
<pointLight position={[0, -50, 5]} intensity={0.1} color="#5bd1d8" />
|
|
||||||
|
|
||||||
<ScrollCamera />
|
<TraceCamera />
|
||||||
<Scene />
|
<PCBScene />
|
||||||
|
|
||||||
<EffectComposer>
|
<EffectComposer>
|
||||||
<Bloom luminanceThreshold={0.5} luminanceSmoothing={0.9} intensity={0.6} mipmapBlur />
|
<Bloom luminanceThreshold={0.4} luminanceSmoothing={0.9} intensity={0.8} mipmapBlur />
|
||||||
<ChromaticAberration blendFunction={BlendFunction.NORMAL} offset={new THREE.Vector2(0.001, 0.001)} />
|
<ChromaticAberration blendFunction={BlendFunction.NORMAL} offset={new THREE.Vector2(0.0008, 0.0008)} />
|
||||||
<GlitchEffect delay={new THREE.Vector2(5, 15)} duration={new THREE.Vector2(0.05, 0.2)} strength={new THREE.Vector2(0.02, 0.08)} mode={GlitchMode.SPORADIC} />
|
<Scanline blendFunction={BlendFunction.OVERLAY} density={2} opacity={0.04} />
|
||||||
<Scanline blendFunction={BlendFunction.OVERLAY} density={1.5} opacity={0.03} />
|
|
||||||
</EffectComposer>
|
</EffectComposer>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+127
-3
@@ -130,9 +130,11 @@ const headerItems = [
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- CONTACT -->
|
<!-- CONTACT — hidden by default, appears as 3D tile on click or end of scroll -->
|
||||||
<section class="section-dark section-contact webgl-section" id="contact">
|
<section class="section-dark section-contact contact-3d-popup" id="contact">
|
||||||
<div class="section-content webgl-card" style="--card-accent: #ff6b35">
|
<div class="contact-3d-backdrop" id="contact-backdrop"></div>
|
||||||
|
<div class="section-content contact-3d-tile" id="contact-tile">
|
||||||
|
<button class="contact-3d-close" id="contact-close" aria-label="Fermer">✕</button>
|
||||||
<Contact />
|
<Contact />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -325,8 +327,86 @@ const headerItems = [
|
|||||||
color: #5bd1d8 !important;
|
color: #5bd1d8 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ CONTACT 3D POPUP ============ */
|
||||||
|
.contact-3d-popup {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.4s ease, visibility 0.4s ease;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-3d-popup.contact-3d-popup--open {
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-3d-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-3d-tile {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(10, 10, 10, 0.9);
|
||||||
|
border: 1px solid rgba(91, 209, 216, 0.15);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: clamp(20px, 4vw, 40px);
|
||||||
|
box-shadow:
|
||||||
|
0 0 40px rgba(91, 209, 216, 0.08),
|
||||||
|
0 24px 80px rgba(0, 0, 0, 0.6);
|
||||||
|
transform: perspective(1200px) rotateY(-15deg) rotateX(5deg) scale(0.85) translateY(40px);
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.7s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-3d-popup--open .contact-3d-tile {
|
||||||
|
transform: perspective(1200px) rotateY(0deg) rotateX(0deg) scale(1) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-3d-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.contact-3d-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #ffffff;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.section-hero { min-height: auto; }
|
.section-hero { min-height: auto; }
|
||||||
|
.contact-3d-tile { transform: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -410,9 +490,53 @@ const headerItems = [
|
|||||||
initScrollAnimations();
|
initScrollAnimations();
|
||||||
setTimeout(initScrollTilt, 2000);
|
setTimeout(initScrollTilt, 2000);
|
||||||
initCarousels();
|
initCarousels();
|
||||||
|
// Contact 3D popup
|
||||||
|
function initContactPopup() {
|
||||||
|
const popup = document.getElementById('contact');
|
||||||
|
const backdrop = document.getElementById('contact-backdrop');
|
||||||
|
const closeBtn = document.getElementById('contact-close');
|
||||||
|
if (!popup) return;
|
||||||
|
|
||||||
|
function openContact() {
|
||||||
|
popup.classList.add('contact-3d-popup--open');
|
||||||
|
}
|
||||||
|
function closeContact() {
|
||||||
|
popup.classList.remove('contact-3d-popup--open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open on any #contact link click
|
||||||
|
document.querySelectorAll('a[href="#contact"]').forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openContact();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open at end of scroll (>95%)
|
||||||
|
let endTriggered = false;
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
const el = document.documentElement;
|
||||||
|
const pct = el.scrollTop / (el.scrollHeight - el.clientHeight);
|
||||||
|
if (pct > 0.95 && !endTriggered) {
|
||||||
|
endTriggered = true;
|
||||||
|
openContact();
|
||||||
|
}
|
||||||
|
if (pct < 0.9) endTriggered = false;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// Close
|
||||||
|
backdrop?.addEventListener('click', closeContact);
|
||||||
|
closeBtn?.addEventListener('click', closeContact);
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeContact();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initContactPopup();
|
||||||
document.addEventListener('astro:page-load', () => {
|
document.addEventListener('astro:page-load', () => {
|
||||||
initScrollAnimations();
|
initScrollAnimations();
|
||||||
setTimeout(initScrollTilt, 2000);
|
setTimeout(initScrollTilt, 2000);
|
||||||
initCarousels();
|
initCarousels();
|
||||||
|
initContactPopup();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+58
-12
@@ -58,18 +58,27 @@
|
|||||||
transform: translateY(-2px) scale(1.01);
|
transform: translateY(-2px) scale(1.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Section atomic reveal ──────────────────────────── */
|
/* ── Section atomic reveal — alternate orbit direction ── */
|
||||||
.webgl-section {
|
.webgl-section {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(40px);
|
|
||||||
transition:
|
transition:
|
||||||
opacity 1s cubic-bezier(0.16, 1, 0.3, 1),
|
opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
transform 1s cubic-bezier(0.16, 1, 0.3, 1);
|
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Odd sections slide from left orbit */
|
||||||
|
.webgl-section:nth-child(odd) {
|
||||||
|
transform: translateX(-60px) rotate(-1deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Even sections slide from right orbit */
|
||||||
|
.webgl-section:nth-child(even) {
|
||||||
|
transform: translateX(60px) rotate(1deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.webgl-section--visible {
|
.webgl-section--visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateX(0) rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Staggered children reveal */
|
/* Staggered children reveal */
|
||||||
@@ -224,24 +233,61 @@
|
|||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Alternating left/right orbit entry */
|
||||||
.webgl-card--visible {
|
.webgl-card--visible {
|
||||||
animation: card-3d-enter 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
animation: card-orbit-right 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes card-3d-enter {
|
/* Even cards enter from right, odd from left */
|
||||||
|
.webgl-section:nth-child(odd) .webgl-card--visible {
|
||||||
|
animation-name: card-orbit-left;
|
||||||
|
}
|
||||||
|
.webgl-section:nth-child(even) .webgl-card--visible {
|
||||||
|
animation-name: card-orbit-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-orbit-left {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: perspective(1000px) rotateX(8deg) rotateY(-5deg) translateY(40px) scale(0.92);
|
transform: perspective(1200px) translateX(-120px) rotateY(25deg) rotateZ(-3deg) scale(0.85);
|
||||||
filter: blur(6px);
|
filter: blur(8px);
|
||||||
}
|
}
|
||||||
60% {
|
40% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: perspective(1200px) translateX(-20px) rotateY(8deg) rotateZ(-1deg) scale(0.97);
|
||||||
|
filter: blur(2px);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: perspective(1000px) rotateX(-2deg) rotateY(1deg) translateY(-5px) scale(1.01);
|
transform: perspective(1200px) translateX(8px) rotateY(-2deg) rotateZ(0.5deg) scale(1.01);
|
||||||
filter: blur(0);
|
filter: blur(0);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: perspective(1000px) rotateX(0deg) rotateY(0deg) translateY(0) scale(1);
|
transform: perspective(1200px) translateX(0) rotateY(0deg) rotateZ(0deg) scale(1);
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-orbit-right {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: perspective(1200px) translateX(120px) rotateY(-25deg) rotateZ(3deg) scale(0.85);
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: perspective(1200px) translateX(20px) rotateY(-8deg) rotateZ(1deg) scale(0.97);
|
||||||
|
filter: blur(2px);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: perspective(1200px) translateX(-8px) rotateY(2deg) rotateZ(-0.5deg) scale(1.01);
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: perspective(1200px) translateX(0) rotateY(0deg) rotateZ(0deg) scale(1);
|
||||||
filter: blur(0);
|
filter: blur(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user