diff --git a/components/GlassesModelSection.tsx b/components/GlassesModelSection.tsx index 4dafeb2..fdfdf5c 100644 --- a/components/GlassesModelSection.tsx +++ b/components/GlassesModelSection.tsx @@ -3,11 +3,22 @@ import { motion } from "framer-motion"; import { useEffect, useRef } from "react"; import * as THREE from "three"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import type { Messages } from "@/messages"; +const MODEL_URL = "/models/ray-ban-meta-smart-glasses/Untitled.glb"; +const ANCHORS = [ + new THREE.Vector3(-0.78, 0.12, 0.2), + new THREE.Vector3(0.92, 0.08, 0.22), + new THREE.Vector3(0, -0.24, 0.26) +]; + export default function GlassesModelSection({ t }: { t: Messages }) { const canvasRef = useRef(null); const wrapRef = useRef(null); + const annotationRefs = useRef>([]); + const anchorDotRefs = useRef>([]); + const lineRefs = useRef>([]); const pointerRef = useRef({ x: 0, y: 0 }); useEffect(() => { @@ -27,7 +38,15 @@ export default function GlassesModelSection({ t }: { t: Messages }) { renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.08; - const group = createGlassesModel(); + const group = new THREE.Group(); + const anchorObjects = ANCHORS.map((position) => { + const anchor = new THREE.Object3D(); + anchor.position.copy(position); + group.add(anchor); + return anchor; + }); + const fallback = createFallbackGlassesModel(); + group.add(fallback); group.position.y = -0.34; scene.add(group); scene.add(new THREE.HemisphereLight(0xffffff, 0xaeb9c6, 1.8)); @@ -40,10 +59,34 @@ export default function GlassesModelSection({ t }: { t: Messages }) { rim.position.set(-4, 1.8, -2); scene.add(rim); + const shadow = new THREE.Mesh( + new THREE.CircleGeometry(2.8, 96), + new THREE.MeshBasicMaterial({ color: 0x315f8f, transparent: true, opacity: 0.08, depthWrite: false }) + ); + shadow.position.set(0, -1.12, -0.92); + shadow.scale.y = 0.18; + group.add(shadow); + + const loader = new GLTFLoader(); + loader.load( + MODEL_URL, + (gltf) => { + if (disposed) return; + fallback.visible = false; + const model = gltf.scene; + normalizeModel(model); + group.add(model); + }, + undefined, + () => { + fallback.visible = true; + } + ); + const resize = () => { const { width, height } = wrap.getBoundingClientRect(); const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.7); - const scale = width < 640 ? 0.5 : width < 1024 ? 0.68 : 0.9; + const scale = width < 640 ? 0.42 : width < 1024 ? 0.68 : 0.9; renderer.setPixelRatio(pixelRatio); renderer.setSize(width, height, false); group.scale.setScalar(scale); @@ -52,6 +95,39 @@ export default function GlassesModelSection({ t }: { t: Messages }) { camera.updateProjectionMatrix(); }; + const updateAnnotations = () => { + const bounds = wrap.getBoundingClientRect(); + const projected = new THREE.Vector3(); + const world = new THREE.Vector3(); + + anchorObjects.forEach((anchor, index) => { + anchor.getWorldPosition(world); + projected.copy(world).project(camera); + const x = (projected.x * 0.5 + 0.5) * bounds.width; + const y = (-projected.y * 0.5 + 0.5) * bounds.height; + const visible = projected.z > -1 && projected.z < 1; + const dot = anchorDotRefs.current[index]; + const line = lineRefs.current[index]; + const annotation = annotationRefs.current[index]; + + if (dot) { + dot.style.opacity = visible ? "1" : "0"; + dot.style.transform = `translate3d(${x}px, ${y}px, 0) translate(-50%, -50%)`; + } + + if (line && annotation) { + const labelBounds = annotation.getBoundingClientRect(); + const labelX = labelBounds.left - bounds.left + (index === 1 ? labelBounds.width - 8 : 8); + const labelY = labelBounds.top - bounds.top + 12; + line.setAttribute("x1", String(x)); + line.setAttribute("y1", String(y)); + line.setAttribute("x2", String(labelX)); + line.setAttribute("y2", String(labelY)); + line.style.opacity = visible ? "0.62" : "0"; + } + }); + }; + const observer = new ResizeObserver(resize); observer.observe(wrap); resize(); @@ -79,6 +155,7 @@ export default function GlassesModelSection({ t }: { t: Messages }) { group.rotation.z = Math.sin(performance.now() * 0.00062) * 0.025; renderer.render(scene, camera); + updateAnnotations(); frame = window.requestAnimationFrame(animate); }; @@ -106,6 +183,16 @@ export default function GlassesModelSection({ t }: { t: Messages }) {
+ + {t.model.annotations.map((item, index) => ( + { lineRefs.current[index] = node; }} className="transition-opacity duration-200" stroke="rgba(49,95,143,0.55)" strokeWidth="1.5" strokeDasharray="5 7" /> + ))} + + {t.model.annotations.map((item, index) => ( + { anchorDotRefs.current[index] = node; }} className="pointer-events-none absolute left-0 top-0 z-[9] grid size-4 place-items-center rounded-full bg-optical shadow-[0_0_0_8px_rgba(49,95,143,0.12)] transition-opacity duration-200" aria-hidden> + + + ))}

{t.model.eyebrow}

@@ -113,17 +200,18 @@ export default function GlassesModelSection({ t }: { t: Messages }) {

{t.model.body}

- - - + { annotationRefs.current[0] = node; }} className="left-4 top-[17rem] sm:left-10 lg:left-16 lg:top-[24rem]" align="left" label={t.model.annotations[0].label} body={t.model.annotations[0].body} /> + { annotationRefs.current[1] = node; }} className="right-4 top-[33rem] sm:right-10 lg:right-20 lg:top-[24rem]" align="right" label={t.model.annotations[1].label} body={t.model.annotations[1].body} /> + { annotationRefs.current[2] = node; }} className="bottom-8 left-6 sm:left-16 lg:bottom-24 lg:left-[42%]" align="left" label={t.model.annotations[2].label} body={t.model.annotations[2].body} />
); } -function Annotation({ label, body, align, className }: { label: string; body: string; align: "left" | "right"; className: string }) { +function Annotation({ label, body, align, className, refCallback }: { label: string; body: string; align: "left" | "right"; className: string; refCallback: (node: HTMLDivElement | null) => void }) { return ( { + if (!(object instanceof THREE.Mesh)) return; + object.castShadow = false; + object.receiveShadow = false; + const material = object.material; + const materials = Array.isArray(material) ? material : [material]; + materials.forEach((item) => { + if ("roughness" in item) item.roughness = Math.max(0.26, item.roughness ?? 0.26); + if ("metalness" in item) item.metalness = Math.min(0.65, item.metalness ?? 0.35); + }); + }); +} + +function createFallbackGlassesModel() { const group = new THREE.Group(); group.scale.setScalar(0.9); @@ -230,14 +339,6 @@ function createGlassesModel() { 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; } diff --git a/public/models/ray-ban-meta-smart-glasses/RAY BAN white_1.png b/public/models/ray-ban-meta-smart-glasses/RAY BAN white_1.png new file mode 100644 index 0000000..0ae2885 Binary files /dev/null and b/public/models/ray-ban-meta-smart-glasses/RAY BAN white_1.png differ diff --git a/public/models/ray-ban-meta-smart-glasses/RB white_0.png b/public/models/ray-ban-meta-smart-glasses/RB white_0.png new file mode 100644 index 0000000..9f4a412 Binary files /dev/null and b/public/models/ray-ban-meta-smart-glasses/RB white_0.png differ diff --git a/public/models/ray-ban-meta-smart-glasses/Untitled.glb b/public/models/ray-ban-meta-smart-glasses/Untitled.glb new file mode 100644 index 0000000..b917b7d Binary files /dev/null and b/public/models/ray-ban-meta-smart-glasses/Untitled.glb differ