Add annotated glasses 3D showcase

This commit is contained in:
2026-05-16 18:43:40 +01:00
parent 0ac37261a8
commit 24beb5aa5e
8 changed files with 346 additions and 3 deletions

View File

@@ -0,0 +1,241 @@
"use client";
import { motion } from "framer-motion";
import { useEffect, useRef } from "react";
import * as THREE from "three";
import type { Messages } from "@/messages";
export default function GlassesModelSection({ t }: { t: Messages }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
const pointerRef = useRef({ x: 0, y: 0 });
useEffect(() => {
const canvas = canvasRef.current;
const wrap = wrapRef.current;
if (!canvas || !wrap) return;
let frame = 0;
let disposed = false;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100);
camera.position.set(0, 0.2, 7.2);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "high-performance" });
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
const group = createGlassesModel();
scene.add(group);
scene.add(new THREE.HemisphereLight(0xffffff, 0xaeb9c6, 1.8));
const key = new THREE.DirectionalLight(0xffffff, 3.8);
key.position.set(3.4, 4.6, 5.2);
scene.add(key);
const rim = new THREE.DirectionalLight(0x9ec7ff, 1.7);
rim.position.set(-4, 1.8, -2);
scene.add(rim);
const resize = () => {
const { width, height } = wrap.getBoundingClientRect();
const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.7);
renderer.setPixelRatio(pixelRatio);
renderer.setSize(width, height, false);
camera.aspect = width / Math.max(height, 1);
camera.updateProjectionMatrix();
};
const observer = new ResizeObserver(resize);
observer.observe(wrap);
resize();
const handlePointerMove = (event: PointerEvent) => {
const bounds = wrap.getBoundingClientRect();
pointerRef.current.x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 2;
pointerRef.current.y = ((event.clientY - bounds.top) / bounds.height - 0.5) * 2;
};
const handlePointerLeave = () => {
pointerRef.current.x = 0;
pointerRef.current.y = 0;
};
wrap.addEventListener("pointermove", handlePointerMove);
wrap.addEventListener("pointerleave", handlePointerLeave);
const animate = () => {
if (disposed) return;
const targetY = pointerRef.current.x * 0.28;
const targetX = pointerRef.current.y * 0.16;
group.rotation.y += (targetY + Math.sin(performance.now() * 0.00045) * 0.22 - group.rotation.y) * 0.055;
group.rotation.x += (-0.08 - targetX - group.rotation.x) * 0.055;
group.rotation.z = Math.sin(performance.now() * 0.00062) * 0.025;
renderer.render(scene, camera);
frame = window.requestAnimationFrame(animate);
};
animate();
return () => {
disposed = true;
window.cancelAnimationFrame(frame);
observer.disconnect();
wrap.removeEventListener("pointermove", handlePointerMove);
wrap.removeEventListener("pointerleave", handlePointerLeave);
scene.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
object.geometry.dispose();
const material = object.material;
if (Array.isArray(material)) material.forEach((item) => item.dispose());
else material.dispose();
});
renderer.dispose();
};
}, []);
return (
<section id="model" className="relative px-4 py-20 sm:px-6 lg:py-28">
<div className="mx-auto max-w-7xl">
<div className="relative z-10 max-w-3xl">
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.28em] text-optical/75">{t.model.eyebrow}</p>
<h2 className="text-4xl font-semibold tracking-[-0.055em] text-ink sm:text-6xl">{t.model.title}</h2>
<p className="mt-5 max-w-2xl text-base leading-8 text-ink/62 sm:text-lg">{t.model.body}</p>
</div>
<div ref={wrapRef} className="relative left-1/2 mt-8 min-h-[32rem] w-screen -translate-x-1/2 overflow-hidden lg:min-h-[38rem]" data-testid="glasses-3d-wrap">
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full touch-pan-y" aria-label={t.model.canvasLabel} data-testid="glasses-3d-canvas" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_45%,rgba(255,255,255,0.58),transparent_34%),radial-gradient(circle_at_50%_72%,rgba(49,95,143,0.15),transparent_28%)]" />
<Annotation className="left-4 top-8 sm:left-10 lg:left-16 lg:top-20" align="left" label={t.model.annotations[0].label} body={t.model.annotations[0].body} />
<Annotation className="right-4 top-36 sm:right-10 lg:right-20 lg:top-24" align="right" label={t.model.annotations[1].label} body={t.model.annotations[1].body} />
<Annotation className="bottom-8 left-6 sm:left-16 lg:bottom-16 lg:left-[42%]" align="left" label={t.model.annotations[2].label} body={t.model.annotations[2].body} />
</div>
</div>
</section>
);
}
function Annotation({ label, body, align, className }: { label: string; body: string; align: "left" | "right"; className: string }) {
return (
<motion.div
className={`pointer-events-none absolute z-10 max-w-[14rem] ${className} ${align === "right" ? "text-right" : "text-left"}`}
initial={{ opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.6 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
<div className={`mb-3 flex items-center gap-3 ${align === "right" ? "justify-end" : ""}`}>
{align === "right" ? <span className="h-px w-16 bg-ink/18" /> : null}
<span className="grid size-3 place-items-center rounded-full bg-optical shadow-[0_0_0_7px_rgba(49,95,143,0.12)]" />
{align === "left" ? <span className="h-px w-16 bg-ink/18" /> : null}
</div>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-optical/80">{label}</p>
<p className="mt-2 text-sm leading-6 text-ink/60">{body}</p>
</motion.div>
);
}
function createGlassesModel() {
const group = new THREE.Group();
group.scale.setScalar(1.08);
const frameMaterial = new THREE.MeshPhysicalMaterial({
color: 0x161a20,
roughness: 0.28,
metalness: 0.55,
clearcoat: 0.85,
clearcoatRoughness: 0.18
});
const lensMaterial = new THREE.MeshPhysicalMaterial({
color: 0xbfd7e3,
roughness: 0.12,
metalness: 0,
transmission: 0.48,
transparent: true,
opacity: 0.28,
thickness: 0.12,
side: THREE.DoubleSide
});
const padMaterial = new THREE.MeshPhysicalMaterial({
color: 0xf8fbff,
roughness: 0.18,
transparent: true,
opacity: 0.52
});
const accentMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.42 });
const ringGeometry = new THREE.TorusGeometry(1.04, 0.065, 18, 112);
const lensGeometry = new THREE.CircleGeometry(0.93, 72);
const hingeGeometry = new THREE.BoxGeometry(0.22, 0.22, 0.22);
const padGeometry = new THREE.SphereGeometry(0.16, 24, 16);
[-1.18, 1.18].forEach((xPosition) => {
const ring = new THREE.Mesh(ringGeometry, frameMaterial);
ring.position.x = xPosition;
ring.scale.y = 0.68;
group.add(ring);
const lens = new THREE.Mesh(lensGeometry, lensMaterial);
lens.position.set(xPosition, 0, -0.03);
lens.scale.set(1, 0.66, 1);
group.add(lens);
const highlight = new THREE.Mesh(new THREE.PlaneGeometry(0.58, 0.045), accentMaterial);
highlight.position.set(xPosition - 0.2, 0.34, 0.02);
highlight.rotation.z = -0.22;
group.add(highlight);
const hinge = new THREE.Mesh(hingeGeometry, frameMaterial);
hinge.position.set(xPosition > 0 ? 2.18 : -2.18, 0.03, -0.02);
hinge.scale.set(0.55, 0.8, 0.62);
group.add(hinge);
});
const bridgeCurve = new THREE.CatmullRomCurve3([
new THREE.Vector3(-0.3, 0.08, 0),
new THREE.Vector3(-0.12, 0.25, 0.04),
new THREE.Vector3(0, 0.3, 0.05),
new THREE.Vector3(0.12, 0.25, 0.04),
new THREE.Vector3(0.3, 0.08, 0)
]);
group.add(new THREE.Mesh(new THREE.TubeGeometry(bridgeCurve, 38, 0.052, 14, false), frameMaterial));
[-0.28, 0.28].forEach((xPosition) => {
const pad = new THREE.Mesh(padGeometry, padMaterial);
pad.position.set(xPosition, -0.22, 0.14);
pad.scale.set(0.55, 0.9, 0.28);
pad.rotation.z = xPosition > 0 ? -0.22 : 0.22;
group.add(pad);
});
const leftTemple = new THREE.CatmullRomCurve3([
new THREE.Vector3(-2.18, 0.03, -0.03),
new THREE.Vector3(-2.75, 0.03, -0.72),
new THREE.Vector3(-3.1, -0.04, -1.75),
new THREE.Vector3(-2.84, -0.28, -2.42)
]);
const rightTemple = new THREE.CatmullRomCurve3([
new THREE.Vector3(2.18, 0.03, -0.03),
new THREE.Vector3(2.75, 0.03, -0.72),
new THREE.Vector3(3.1, -0.04, -1.75),
new THREE.Vector3(2.84, -0.28, -2.42)
]);
group.add(new THREE.Mesh(new THREE.TubeGeometry(leftTemple, 54, 0.055, 14, false), frameMaterial));
group.add(new THREE.Mesh(new THREE.TubeGeometry(rightTemple, 54, 0.055, 14, false), frameMaterial));
const shadow = new THREE.Mesh(
new THREE.CircleGeometry(2.6, 96),
new THREE.MeshBasicMaterial({ color: 0x315f8f, transparent: true, opacity: 0.08, depthWrite: false })
);
shadow.position.set(0, -1.06, -0.92);
shadow.scale.y = 0.18;
group.add(shadow);
group.rotation.set(-0.08, -0.2, 0);
return group;
}