207 lines
7.6 KiB
TypeScript
207 lines
7.6 KiB
TypeScript
"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 <tonemapping_fragment>
|
|
#include <colorspace_fragment>
|
|
}`;
|
|
export function Panorama({
|
|
urls,
|
|
zoom,
|
|
onReady,
|
|
onError,
|
|
}: {
|
|
urls: string[];
|
|
zoom: boolean;
|
|
onReady: () => void;
|
|
onError: (message: string) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const mount = useRef<HTMLDivElement>(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 (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]);
|
|
return (
|
|
<>
|
|
<div
|
|
className="panorama"
|
|
ref={mount}
|
|
data-native-cursor
|
|
aria-label={t("360 degree panorama: drag to look around")}
|
|
/>
|
|
{
|
|
<div className="panorama-controls">
|
|
{zoom && (
|
|
<>
|
|
<button
|
|
aria-label={t("Zoom in")}
|
|
onClick={() => {
|
|
view.current.fov = Math.max(35, view.current.fov - 10);
|
|
}}
|
|
>
|
|
<Plus size={17} />
|
|
</button>
|
|
<button
|
|
aria-label={t("Zoom out")}
|
|
onClick={() => {
|
|
view.current.fov = Math.min(100, view.current.fov + 10);
|
|
}}
|
|
>
|
|
<Minus size={17} />
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
aria-label={t("Reset view")}
|
|
onClick={() => {
|
|
view.current = { yaw: 0, pitch: 0, fov: 80 };
|
|
}}
|
|
>
|
|
<RotateCcw size={17} />
|
|
</button>
|
|
</div>
|
|
}
|
|
</>
|
|
);
|
|
}
|