// family-bottle.jsx — Tiny Three.js viewer for each family card. // Reuses the bronze/satin metal aesthetic from the hero. // Strips label/text meshes (same heuristic as hero). // Falls back to the SVG Bottle component if the GLB fails or doesn't exist yet. const { useEffect: useEffectFB, useRef: useRefFB, useState: useStateFB } = React; function isGroundMeshFB(mesh, fullBox) { if (!mesh.geometry) return false; if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox(); const bb = mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld); const sx = bb.max.x - bb.min.x, sy = bb.max.y - bb.min.y, sz = bb.max.z - bb.min.z; const fullH = fullBox.max.y - fullBox.min.y; const fullW = Math.max(fullBox.max.x - fullBox.min.x, fullBox.max.z - fullBox.min.z); // very flat in Y, wide in X/Z, sitting at the bottom of the model const flatRatio = sy / Math.max(sx, sz, 0.0001); const wideEnough = Math.max(sx, sz) > fullW * 0.55; const atBottom = bb.max.y < fullBox.min.y + fullH * 0.10; if (flatRatio < 0.08 && wideEnough && atBottom) return true; // also catch ground by name const n = (mesh.name || "").toLowerCase(); if (/ground|floor|plane|base|zemin|pedestal|stand/.test(n)) return true; return false; } function isLabelMeshFB(mesh, fullBox) { const name = (mesh.name || "").toLowerCase(); if (/text|label|logo|decal|sticker|brand|writing|inscription|engrav/.test(name)) return true; const mat = mesh.material; if (mat) { const mname = (mat.name || "").toLowerCase(); if (/text|label|logo|decal/.test(mname)) return true; } if (!mesh.geometry) return false; if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox(); const bb = mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld); const sx = bb.max.x - bb.min.x, sy = bb.max.y - bb.min.y, sz = bb.max.z - bb.min.z; const minDim = Math.min(sx, sy, sz), maxDim = Math.max(sx, sy, sz); const bodySize = Math.max(fullBox.max.x - fullBox.min.x, fullBox.max.y - fullBox.min.y, fullBox.max.z - fullBox.min.z); if (minDim < bodySize * 0.012 && maxDim < bodySize * 0.45) return true; return false; } // Cache: glb url → THREE.Group prototype (cloned per-instance) const _glbCache = new Map(); function loadGLB(THREE, url) { if (_glbCache.has(url)) return _glbCache.get(url); const p = new Promise((resolve, reject) => { const loader = new THREE.GLTFLoader(); loader.load(url, (g) => resolve(g.scene), undefined, (e) => reject(e)); }); _glbCache.set(url, p); return p; } // Single shared PMREM env (warm bronze studio) across all family bottles let _sharedEnvTex = null; function buildFamilyEnv(THREE, renderer) { if (_sharedEnvTex) return _sharedEnvTex; const envScene = new THREE.Scene(); const bgGeo = new THREE.SphereGeometry(50, 48, 48); const bgMat = new THREE.ShaderMaterial({ side: THREE.BackSide, vertexShader: `varying vec3 vN; void main(){ vN = normalize(position); gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);} `, fragmentShader: `varying vec3 vN; void main(){ float t = clamp(vN.y * 0.5 + 0.5, 0.0, 1.0); vec3 top = vec3(0.42, 0.34, 0.22); vec3 bot = vec3(0.04, 0.04, 0.04); vec3 col = mix(bot, top, smoothstep(0.0, 1.0, t)); gl_FragColor = vec4(col, 1.0); }` }); envScene.add(new THREE.Mesh(bgGeo, bgMat)); const sb = (w,h,c,p)=>{ const m = new THREE.Mesh(new THREE.PlaneGeometry(w,h), new THREE.MeshBasicMaterial({color:c})); m.position.set(...p); m.lookAt(0,0,0); envScene.add(m); }; sb(8,12,0xffd9a8,[6,4,4]); sb(6,8, 0xb8a888,[-5,3,3]); sb(6,8, 0xfff2dc,[0,4,-6]); sb(4,4, 0xa07a4a,[3,-2,2]); const pmrem = new THREE.PMREMGenerator(renderer); pmrem.compileEquirectangularShader(); const target = pmrem.fromScene(envScene, 0.04); pmrem.dispose(); _sharedEnvTex = target.texture; return _sharedEnvTex; } function FamilyBottle3D({ url, accent, hovered }) { const ref = useRefFB(null); const stateRef = useRefFB({}); const [failed, setFailed] = useStateFB(false); useEffectFB(() => { const THREE = window.THREE; if (!THREE) { setFailed(true); return; } const mount = ref.current; const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); if (THREE.SRGBColorSpace) renderer.outputColorSpace = THREE.SRGBColorSpace; else renderer.outputEncoding = THREE.sRGBEncoding; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 0.85; const w = mount.clientWidth, h = mount.clientHeight; renderer.setSize(w, h); mount.appendChild(renderer.domElement); renderer.domElement.style.display = "block"; renderer.domElement.style.width = "100%"; renderer.domElement.style.height = "100%"; const scene = new THREE.Scene(); scene.environment = buildFamilyEnv(THREE, renderer); scene.add(new THREE.AmbientLight(0xffffff, 0.04)); scene.add(new THREE.HemisphereLight(0x554433, 0x080604, 0.10)); const key = new THREE.SpotLight(0xfff0d8, 28, 30, Math.PI/5, 0.4, 1.5); key.position.set(4,6,4); scene.add(key); const fill = new THREE.SpotLight(0xc8b89a, 8, 30, Math.PI/4, 0.4, 1.5); fill.position.set(-3.5,4,3); scene.add(fill); const rim = new THREE.SpotLight(0xffe8c0, 22, 30, Math.PI/5, 0.4, 1.5); rim.position.set(-2,4,-5); scene.add(rim); const accentLight = new THREE.PointLight(new THREE.Color(accent), 3, 6, 2); accentLight.position.set(1.2,0.6,1.2); scene.add(accentLight); const camera = new THREE.PerspectiveCamera(26, w/h, 0.1, 100); camera.position.set(0, 1.2, 5.5); camera.lookAt(0, 0.7, 0); const bottleGroup = new THREE.Group(); scene.add(bottleGroup); const bronzeMat = new THREE.MeshPhysicalMaterial({ color: 0x6a6358, metalness: 0.92, roughness: 0.12, ior: 1.46, clearcoat: 0.85, clearcoatRoughness: 0.06, envMapIntensity: 1.0, side: THREE.FrontSide, flatShading: false, }); stateRef.current = { renderer, scene, camera, bottleGroup, accentLight, bronzeMat, mount, hovered:0, mounted:true }; loadGLB(THREE, url).then((sceneProto) => { if (!stateRef.current.mounted) return; const root = sceneProto.clone(true); root.updateMatrixWorld(true); const fullBox = new THREE.Box3().setFromObject(root); const meshes = []; root.traverse(n => { if (n.isMesh) meshes.push(n); }); const toRemove = []; meshes.forEach((node) => { node.updateMatrixWorld(true); if (isLabelMeshFB(node, fullBox) || isGroundMeshFB(node, fullBox)) { toRemove.push(node); return; } // Weld duplicate vertices so neighbouring faces share normals → smooth shading. try { if (THREE.BufferGeometryUtils && THREE.BufferGeometryUtils.mergeVertices) { const merged = THREE.BufferGeometryUtils.mergeVertices(node.geometry, 1e-4); node.geometry.dispose(); node.geometry = merged; } } catch (e) { /* fall through to raw normals */ } node.geometry.computeVertexNormals(); node.material = bronzeMat; }); toRemove.forEach(m => { if (m.parent) m.parent.remove(m); }); const box = new THREE.Box3().setFromObject(root); const size = new THREE.Vector3(); box.getSize(size); const targetH = 1.8; const scale = targetH / Math.max(size.y, 0.0001); root.scale.setScalar(scale); root.updateMatrixWorld(true); const box2 = new THREE.Box3().setFromObject(root); const center2 = new THREE.Vector3(); box2.getCenter(center2); root.position.x -= center2.x; root.position.z -= center2.z; root.position.y -= box2.min.y - 0.0; bottleGroup.add(root); }).catch(() => setFailed(true)); let raf; let last = performance.now(); const start = last; const loop = (now) => { const dt = Math.min(0.05, (now - last) / 1000); last = now; const t = (now - start) / 1000; const speed = 0.25 + (stateRef.current.hovered ? 0.45 : 0); bottleGroup.rotation.y += speed * dt; camera.position.y = 1.2 + Math.sin(t * 0.4) * 0.015; camera.lookAt(0, 0.7, 0); renderer.render(scene, camera); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); const onResize = () => { const w2 = mount.clientWidth, h2 = mount.clientHeight; if (w2 < 4 || h2 < 4) return; renderer.setSize(w2, h2); camera.aspect = w2/h2; camera.updateProjectionMatrix(); }; window.addEventListener("resize", onResize); return () => { stateRef.current.mounted = false; cancelAnimationFrame(raf); window.removeEventListener("resize", onResize); renderer.dispose(); if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement); }; }, [url]); useEffectFB(() => { const s = stateRef.current; if (!s || !s.accentLight) return; const THREE = window.THREE; s.accentLight.color = new THREE.Color(accent); }, [accent]); useEffectFB(() => { if (stateRef.current) stateRef.current.hovered = hovered ? 1 : 0; }, [hovered]); if (failed) return null; return
; } window.FamilyBottle3D = FamilyBottle3D;