Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd3c9da39a | |||
| d68a652a64 | |||
| 954ac4f502 |
@@ -3,11 +3,22 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
import type { Messages } from "@/messages";
|
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 }) {
|
export default function GlassesModelSection({ t }: { t: Messages }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const annotationRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||||
|
const anchorDotRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
||||||
|
const lineRefs = useRef<Array<SVGLineElement | null>>([]);
|
||||||
const pointerRef = useRef({ x: 0, y: 0 });
|
const pointerRef = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -27,7 +38,15 @@ export default function GlassesModelSection({ t }: { t: Messages }) {
|
|||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
renderer.toneMappingExposure = 1.08;
|
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;
|
group.position.y = -0.34;
|
||||||
scene.add(group);
|
scene.add(group);
|
||||||
scene.add(new THREE.HemisphereLight(0xffffff, 0xaeb9c6, 1.8));
|
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);
|
rim.position.set(-4, 1.8, -2);
|
||||||
scene.add(rim);
|
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 resize = () => {
|
||||||
const { width, height } = wrap.getBoundingClientRect();
|
const { width, height } = wrap.getBoundingClientRect();
|
||||||
const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.7);
|
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.setPixelRatio(pixelRatio);
|
||||||
renderer.setSize(width, height, false);
|
renderer.setSize(width, height, false);
|
||||||
group.scale.setScalar(scale);
|
group.scale.setScalar(scale);
|
||||||
@@ -52,6 +95,39 @@ export default function GlassesModelSection({ t }: { t: Messages }) {
|
|||||||
camera.updateProjectionMatrix();
|
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);
|
const observer = new ResizeObserver(resize);
|
||||||
observer.observe(wrap);
|
observer.observe(wrap);
|
||||||
resize();
|
resize();
|
||||||
@@ -79,6 +155,7 @@ export default function GlassesModelSection({ t }: { t: Messages }) {
|
|||||||
group.rotation.z = Math.sin(performance.now() * 0.00062) * 0.025;
|
group.rotation.z = Math.sin(performance.now() * 0.00062) * 0.025;
|
||||||
|
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
|
updateAnnotations();
|
||||||
frame = window.requestAnimationFrame(animate);
|
frame = window.requestAnimationFrame(animate);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,6 +183,16 @@ export default function GlassesModelSection({ t }: { t: Messages }) {
|
|||||||
<div ref={wrapRef} className="relative left-1/2 min-h-[42rem] w-screen -translate-x-1/2 overflow-hidden lg:min-h-[46rem]" data-testid="glasses-3d-wrap">
|
<div ref={wrapRef} className="relative left-1/2 min-h-[42rem] w-screen -translate-x-1/2 overflow-hidden lg:min-h-[46rem]" 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" />
|
<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_52%_46%,rgba(255,255,255,0.58),transparent_34%),radial-gradient(circle_at_52%_68%,rgba(49,95,143,0.15),transparent_30%)]" />
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_52%_46%,rgba(255,255,255,0.58),transparent_34%),radial-gradient(circle_at_52%_68%,rgba(49,95,143,0.15),transparent_30%)]" />
|
||||||
|
<svg className="pointer-events-none absolute inset-0 z-[8] h-full w-full overflow-visible" aria-hidden>
|
||||||
|
{t.model.annotations.map((item, index) => (
|
||||||
|
<line key={item.label} ref={(node) => { lineRefs.current[index] = node; }} className="transition-opacity duration-200" stroke="rgba(49,95,143,0.55)" strokeWidth="1.5" strokeDasharray="5 7" />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{t.model.annotations.map((item, index) => (
|
||||||
|
<span key={item.label} ref={(node) => { 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>
|
||||||
|
<span className="size-1.5 rounded-full bg-white" />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6">
|
<div className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6">
|
||||||
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.28em] text-optical/75">{t.model.eyebrow}</p>
|
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.28em] text-optical/75">{t.model.eyebrow}</p>
|
||||||
@@ -113,17 +200,18 @@ export default function GlassesModelSection({ t }: { t: Messages }) {
|
|||||||
<p className="mt-5 max-w-2xl text-base leading-8 text-ink/62 sm:text-lg">{t.model.body}</p>
|
<p className="mt-5 max-w-2xl text-base leading-8 text-ink/62 sm:text-lg">{t.model.body}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Annotation 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} />
|
<Annotation refCallback={(node) => { 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} />
|
||||||
<Annotation 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} />
|
<Annotation refCallback={(node) => { 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} />
|
||||||
<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} />
|
<Annotation refCallback={(node) => { 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} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
ref={refCallback}
|
||||||
className={`pointer-events-none absolute z-10 max-w-[14rem] ${className} ${align === "right" ? "text-right" : "text-left"}`}
|
className={`pointer-events-none absolute z-10 max-w-[14rem] ${className} ${align === "right" ? "text-right" : "text-left"}`}
|
||||||
initial={{ opacity: 0, y: 14 }}
|
initial={{ opacity: 0, y: 14 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
@@ -141,7 +229,28 @@ function Annotation({ label, body, align, className }: { label: string; body: st
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGlassesModel() {
|
function normalizeModel(model: THREE.Object3D) {
|
||||||
|
const box = new THREE.Box3().setFromObject(model);
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const maxDimension = Math.max(size.x, size.y, size.z) || 1;
|
||||||
|
model.position.sub(center);
|
||||||
|
model.scale.setScalar(3.25 / maxDimension);
|
||||||
|
model.rotation.set(0, Math.PI, 0);
|
||||||
|
model.traverse((object) => {
|
||||||
|
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();
|
const group = new THREE.Group();
|
||||||
group.scale.setScalar(0.9);
|
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(leftTemple, 54, 0.055, 14, false), frameMaterial));
|
||||||
group.add(new THREE.Mesh(new THREE.TubeGeometry(rightTemple, 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);
|
group.rotation.set(-0.08, -0.2, 0);
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/models/ray-ban-meta-smart-glasses/RAY BAN white_1.png
Normal file
BIN
public/models/ray-ban-meta-smart-glasses/RAY BAN white_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
BIN
public/models/ray-ban-meta-smart-glasses/RB white_0.png
Normal file
BIN
public/models/ray-ban-meta-smart-glasses/RB white_0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
public/models/ray-ban-meta-smart-glasses/Untitled.glb
Normal file
BIN
public/models/ray-ban-meta-smart-glasses/Untitled.glb
Normal file
Binary file not shown.
Reference in New Issue
Block a user