diff --git a/CHANGELOG.md b/CHANGELOG.md index 0856f92..2632c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the New Optic website will be documented in this file. ### Changed +- Added a `3D` branch experiment with an interactive annotated glasses model section. - Removed the desktop liquid glass WebGL layer from navbar and CTA controls so text remains readable and glass surfaces do not show dark rendering artifacts while scrolling. - Replaced the mobile hamburger dropdown with a fast, solid menu animation without bounce or stretch effects. - Kept the language switcher visible in the mobile top navigation bar. diff --git a/components/GlassesModelSection.tsx b/components/GlassesModelSection.tsx new file mode 100644 index 0000000..4dafeb2 --- /dev/null +++ b/components/GlassesModelSection.tsx @@ -0,0 +1,243 @@ +"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(null); + const wrapRef = useRef(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.15, 7.9); + + 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(); + group.position.y = -0.34; + 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); + const scale = width < 640 ? 0.5 : width < 1024 ? 0.68 : 0.9; + renderer.setPixelRatio(pixelRatio); + renderer.setSize(width, height, false); + group.scale.setScalar(scale); + group.position.y = width < 640 ? -0.44 : -0.34; + 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 ( +
+
+ +
+ +
+

{t.model.eyebrow}

+

{t.model.title}

+

{t.model.body}

+
+ + + + +
+
+ ); +} + +function Annotation({ label, body, align, className }: { label: string; body: string; align: "left" | "right"; className: string }) { + return ( + +
+ {align === "right" ? : null} + + {align === "left" ? : null} +
+

{label}

+

{body}

+
+ ); +} + +function createGlassesModel() { + const group = new THREE.Group(); + group.scale.setScalar(0.9); + + 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; +} diff --git a/components/SiteShell.tsx b/components/SiteShell.tsx index 183c582..589ef74 100644 --- a/components/SiteShell.tsx +++ b/components/SiteShell.tsx @@ -7,6 +7,7 @@ import AboutSection from "./AboutSection"; import ContactSection from "./ContactSection"; import CollectionsSection from "./CollectionsSection"; import Footer from "./Footer"; +import GlassesModelSection from "./GlassesModelSection"; import HeroSection from "./HeroSection"; import LiquidGlass from "./LiquidGlass"; import Navbar from "./Navbar"; @@ -38,6 +39,7 @@ export default function SiteShell() {
+ diff --git a/messages/ar.ts b/messages/ar.ts index 34d129c..57c5c51 100644 --- a/messages/ar.ts +++ b/messages/ar.ts @@ -29,6 +29,17 @@ export const ar = { body: "منذ حوالي 2004، يرافق نيو أوبتيك زبناء تمارة بطريقة بسيطة: نصيحة واضحة، نظارات أصلية، أثمنة منصفة وخدمة متابعة بعد الشراء.", cards: ["سمعة محلية قوية", "نظارات أصلية فقط", "مواكبة من اختيار الإطار حتى الضمان"] }, + model: { + eyebrow: "معاينة ثلاثية الأبعاد", + title: "شوف التفاصيل قبل الاختيار.", + body: "معاينة تفاعلية كتوضح المهم: شكل الإطار، الراحة، العدسات والتعديل النهائي.", + canvasLabel: "نموذج ثلاثي الأبعاد لنظارات مع ملاحظات", + annotations: [ + { label: "العدسات", body: "نصيحة في العدسات المناسبة للاستعمال اليومي." }, + { label: "الإطار", body: "إطارات أصلية بجودة وتشطيب واضح." }, + { label: "التعديل", body: "تعديل في المحل لراحة أفضل كل يوم." } + ] + }, services: { eyebrow: "الخدمات", title: "كل ما تحتاجه لنظاراتك في مكان واحد.", diff --git a/messages/en.ts b/messages/en.ts index 8c78388..d74b04d 100644 --- a/messages/en.ts +++ b/messages/en.ts @@ -29,6 +29,17 @@ export const en = { body: "Since around 2004, New Optic has supported customers in Temara with a simple promise: honest guidance, authentic frames, fair prices and reliable after-sales support.", cards: ["Strong local reputation", "Original frames only", "Guidance from frame to warranty"] }, + model: { + eyebrow: "3D inspection", + title: "See the details before choosing.", + body: "An interactive preview for the essentials: shape, comfort, lenses and the final fit.", + canvasLabel: "3D glasses model with annotations", + annotations: [ + { label: "Lenses", body: "Guidance on lenses suited to everyday use." }, + { label: "Frame", body: "Original frames with a clean, durable finish." }, + { label: "Fit", body: "In-store adjustment for daily comfort." } + ] + }, services: { eyebrow: "Services", title: "Everything essential for your eyewear, in one place.", diff --git a/messages/fr.ts b/messages/fr.ts index ba691e3..d274bcb 100644 --- a/messages/fr.ts +++ b/messages/fr.ts @@ -29,6 +29,17 @@ export const fr = { body: "Depuis 2004, New Optic accompagne les clients de Temara avec une approche claire: bien conseiller, proposer des montures authentiques, garder des prix justes et assurer le suivi apres l'achat.", cards: ["Reputation locale solide", "Montures originales uniquement", "Conseil de la monture a la garantie"] }, + model: { + eyebrow: "Inspection 3D", + title: "Regardez les details avant de choisir.", + body: "Un apercu interactif pour montrer ce qui compte vraiment: la forme, le confort, les verres et l'ajustement final.", + canvasLabel: "Modele 3D de lunettes avec annotations", + annotations: [ + { label: "Verres", body: "Conseil sur les verres adaptes a votre usage." }, + { label: "Monture", body: "Selection de montures originales et bien finies." }, + { label: "Ajustement", body: "Reglage en magasin pour le confort au quotidien." } + ] + }, services: { eyebrow: "Services", title: "Tout l'essentiel pour vos lunettes, au meme endroit.", diff --git a/package-lock.json b/package-lock.json index 751ac8b..43500dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,14 @@ "lucide-react": "^0.468.0", "next": "^16.0.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "three": "^0.184.0" }, "devDependencies": { "@types/node": "^22.10.2", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "@types/three": "^0.184.1", "autoprefixer": "^10.4.20", "eslint": "^9.17.0", "eslint-config-next": "^16.0.0", @@ -279,6 +281,13 @@ "node": ">=6.9.0" } }, + "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/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1259,6 +1268,13 @@ "tslib": "^2.8.0" } }, + "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/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1321,6 +1337,35 @@ "@types/react": "^19.2.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.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "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.2", + "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/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", @@ -3411,6 +3456,13 @@ "reusify": "^1.0.4" } }, + "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/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4589,6 +4641,13 @@ "node": ">= 8" } }, + "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/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5084,7 +5143,6 @@ "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -6120,6 +6178,12 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", diff --git a/package.json b/package.json index e043fff..fd24a69 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,14 @@ "lucide-react": "^0.468.0", "next": "^16.0.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "three": "^0.184.0" }, "devDependencies": { "@types/node": "^22.10.2", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "@types/three": "^0.184.1", "autoprefixer": "^10.4.20", "eslint": "^9.17.0", "eslint-config-next": "^16.0.0",