"use client"; import { useI18n } from "@/components/I18nProvider"; import { useEffect, useRef } from "react"; import { Plus, Minus, RotateCcw } from "lucide-react"; import * as THREE from "three"; import { loadPanoramaTextures } from "@/lib/panorama-textures"; // Explicit projection matches the six positive-FOV camera images emitted by // both exporters; it avoids CubeTexture's renderer-dependent orientation. const fragment = `uniform sampler2D px;uniform sampler2D nx;uniform sampler2D py;uniform sampler2D ny;uniform sampler2D pz;uniform sampler2D nz;varying vec3 vDirection; void main(){vec3 d=normalize(vDirection);vec3 a=abs(d);vec2 uv;vec4 color;if(a.x>=a.y&&a.x>=a.z){if(d.x>0.0){uv=vec2(d.z,d.y)/a.x*.5+.5;color=texture2D(px,uv);}else{uv=vec2(-d.z,d.y)/a.x*.5+.5;color=texture2D(nx,uv);}}else if(a.y>=a.z){if(d.y>0.0){uv=vec2(-d.x,-d.z)/a.y*.5+.5;color=texture2D(py,uv);}else{uv=vec2(-d.x,d.z)/a.y*.5+.5;color=texture2D(ny,uv);}}else{if(d.z>0.0){uv=vec2(-d.x,d.y)/a.z*.5+.5;color=texture2D(pz,uv);}else{uv=vec2(d.x,d.y)/a.z*.5+.5;color=texture2D(nz,uv);}}gl_FragColor=color; #include #include }`; export function Panorama({ urls, zoom, pan = true, onReady, onError, }: { urls: string[]; zoom: boolean; pan?: boolean; onReady: () => void; onError: (message: string) => void; }) { const { t } = useI18n(); const mount = useRef(null), view = useRef({ yaw: 0, pitch: 0, fov: 80 }); const callbacks = useRef({ onReady, onError }); callbacks.current = { onReady, onError }; useEffect(() => { let disposed = false, renderer: THREE.WebGLRenderer | undefined, frame = 0, observer: ResizeObserver | undefined; let material: THREE.ShaderMaterial | undefined, geometry: THREE.SphereGeometry | undefined, ownedTextures: THREE.Texture[] = []; const element = mount.current!; view.current = { yaw: 0, pitch: 0, fov: 80 }; const down = (e: PointerEvent) => { if (!pan || e.button !== 0 || drag) return; element.setPointerCapture(e.pointerId); drag = { x: e.clientX, y: e.clientY, pointerId: e.pointerId }; }, move = (e: PointerEvent) => { if (!drag || drag.pointerId !== e.pointerId) return; view.current.yaw -= (e.clientX - drag.x) * 0.003; view.current.pitch = Math.max( -1.5, Math.min(1.5, view.current.pitch + (e.clientY - drag.y) * 0.003), ); drag = { x: e.clientX, y: e.clientY, pointerId: e.pointerId }; }, up = (e: PointerEvent) => { if (drag?.pointerId === e.pointerId) drag = null; }, wheel = (e: WheelEvent) => { if (!zoom) return; e.preventDefault(); view.current.fov = Math.max( 35, Math.min(100, view.current.fov + e.deltaY * 0.04), ); }; const contextLost = (event: Event) => { event.preventDefault(); cancelAnimationFrame(frame); callbacks.current.onError( "The panorama graphics context was lost. Reconnect to restore the view.", ); }; let drag: { x: number; y: number; pointerId: number } | null = null; if (urls.length !== 6) { callbacks.current.onError( "This panorama is missing one or more of its six faces.", ); return; } loadPanoramaTextures(urls) .then((textures) => { if (disposed) { textures.forEach((texture) => texture.dispose()); return; } ownedTextures = textures; renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; element.appendChild(renderer.domElement); renderer.domElement.addEventListener("webglcontextlost", contextLost); const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(80, 1, 0.1, 10); geometry = new THREE.SphereGeometry(5, 32, 16); material = new THREE.ShaderMaterial({ side: THREE.BackSide, uniforms: Object.fromEntries( ["px", "nx", "py", "ny", "pz", "nz"].map((f, i) => [ f, { value: textures[i] }, ]), ), vertexShader: "varying vec3 vDirection;void main(){vDirection=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}", fragmentShader: fragment, }); scene.add(new THREE.Mesh(geometry, material)); const resize = () => { const width = Math.max(1, element.clientWidth), height = Math.max(1, element.clientHeight); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer!.setSize(width, height); }; observer = new ResizeObserver(resize); observer.observe(element); resize(); function draw() { if (disposed) return; const v = view.current; camera.fov = zoom ? v.fov : 80; camera.updateProjectionMatrix(); camera.lookAt( Math.sin(v.yaw) * Math.cos(v.pitch), Math.sin(v.pitch), -Math.cos(v.yaw) * Math.cos(v.pitch), ); renderer!.render(scene, camera); frame = requestAnimationFrame(draw); } draw(); element.addEventListener("pointerdown", down); element.addEventListener("pointermove", move); element.addEventListener("pointerup", up); element.addEventListener("pointercancel", up); element.addEventListener("lostpointercapture", up); element.addEventListener("wheel", wheel, { passive: false }); callbacks.current.onReady(); }) .catch((e) => { if (disposed) return; callbacks.current.onError( "Panorama could not load. " + (e instanceof Error ? e.message : ""), ); }); return () => { disposed = true; cancelAnimationFrame(frame); observer?.disconnect(); element.removeEventListener("pointerdown", down); element.removeEventListener("pointermove", move); element.removeEventListener("pointerup", up); element.removeEventListener("pointercancel", up); element.removeEventListener("lostpointercapture", up); element.removeEventListener("wheel", wheel); geometry?.dispose(); material?.dispose(); ownedTextures.forEach((texture) => texture.dispose()); renderer?.domElement.removeEventListener("webglcontextlost", contextLost); renderer?.dispose(); renderer?.domElement.remove(); }; }, [urls, zoom, pan]); return ( <>
{
{zoom && ( <> )}
} ); }