f518c07763
- Trình xem mô hình .glb (three GLTFLoader + OrbitControls): xoay/zoom/tự xoay, môi trường studio, trạng thái tải & lỗi; trang /co-vat với 2 cổ vật chuông đồng - Đổi tên file .glb Unicode -> ASCII (chuong-1/2.glb) tránh lỗi đường dẫn - Thêm font serif hiển thị (Noto Serif Display) cho tiêu đề - Sửa độ tương phản chữ trên bề mặt sáng (header, ô tìm kiếm, bộ lọc), badge vuông Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
224 lines
8.9 KiB
TypeScript
224 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import * as THREE from "three";
|
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
|
import { Rotate3d, Pause, Play, Maximize2 } from "lucide-react";
|
|
|
|
type Status = "loading" | "ready" | "error";
|
|
|
|
/**
|
|
* Trình xem mô hình 3D (.glb) cho cổ vật: xoay, phóng to, tự xoay.
|
|
* Dùng three.js (đã có sẵn trong dự án) — không thêm thư viện mới.
|
|
*/
|
|
export function AntiquityViewer({ src }: { src: string }) {
|
|
const mountRef = useRef<HTMLDivElement>(null);
|
|
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
|
const controlsRef = useRef<OrbitControls | null>(null);
|
|
const homeRef = useRef<{ pos: THREE.Vector3; target: THREE.Vector3 } | null>(null);
|
|
const autoRef = useRef(true);
|
|
|
|
const [status, setStatus] = useState<Status>("loading");
|
|
const [progress, setProgress] = useState(0);
|
|
const [autoRotate, setAutoRotate] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const mount = mountRef.current;
|
|
if (!mount) return;
|
|
|
|
setStatus("loading");
|
|
setProgress(0);
|
|
|
|
let raf = 0;
|
|
let disposed = false;
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.08;
|
|
renderer.domElement.style.cssText = "width:100%;height:100%;display:block;cursor:grab;";
|
|
mount.appendChild(renderer.domElement);
|
|
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(40, 1, 0.01, 1000);
|
|
cameraRef.current = camera;
|
|
|
|
const pmrem = new THREE.PMREMGenerator(renderer);
|
|
const envTexture = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
scene.environment = envTexture;
|
|
|
|
const keyLight = new THREE.DirectionalLight(0xfff1d6, 2.4);
|
|
keyLight.position.set(2.5, 3.5, 2.2);
|
|
const rimLight = new THREE.DirectionalLight(0xcdd9ff, 1.2);
|
|
rimLight.position.set(-3, 1.8, -2.4);
|
|
const ambient = new THREE.AmbientLight(0xffffff, 0.3);
|
|
scene.add(keyLight, rimLight, ambient);
|
|
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.08;
|
|
controls.enablePan = false;
|
|
controls.autoRotate = autoRef.current;
|
|
controls.autoRotateSpeed = 1.1;
|
|
controls.addEventListener("start", () => {
|
|
renderer.domElement.style.cursor = "grabbing";
|
|
});
|
|
controls.addEventListener("end", () => {
|
|
renderer.domElement.style.cursor = "grab";
|
|
});
|
|
controlsRef.current = controls;
|
|
|
|
const resize = () => {
|
|
const w = mount.clientWidth;
|
|
const h = mount.clientHeight;
|
|
if (!w || !h) return;
|
|
renderer.setSize(w, h, false);
|
|
camera.aspect = w / h;
|
|
camera.updateProjectionMatrix();
|
|
};
|
|
const observer = new ResizeObserver(resize);
|
|
observer.observe(mount);
|
|
resize();
|
|
|
|
const loader = new GLTFLoader();
|
|
loader.load(
|
|
src,
|
|
(gltf) => {
|
|
if (disposed) return;
|
|
const model = gltf.scene;
|
|
|
|
const box = new THREE.Box3().setFromObject(model);
|
|
const size = box.getSize(new THREE.Vector3());
|
|
const center = box.getCenter(new THREE.Vector3());
|
|
model.position.sub(center);
|
|
|
|
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
|
model.scale.setScalar(2 / maxDim);
|
|
scene.add(model);
|
|
|
|
const radius = 1; // mô hình đã chuẩn hóa về ~2 đơn vị
|
|
const dist = (radius / Math.sin((camera.fov * Math.PI) / 360)) * 1.25;
|
|
camera.position.set(dist * 0.62, dist * 0.32, dist * 0.92);
|
|
controls.target.set(0, 0, 0);
|
|
controls.minDistance = dist * 0.55;
|
|
controls.maxDistance = dist * 2.6;
|
|
controls.update();
|
|
|
|
homeRef.current = { pos: camera.position.clone(), target: controls.target.clone() };
|
|
setStatus("ready");
|
|
},
|
|
(event) => {
|
|
if (event.total) setProgress(Math.round((event.loaded / event.total) * 100));
|
|
},
|
|
() => {
|
|
if (!disposed) setStatus("error");
|
|
},
|
|
);
|
|
|
|
const tick = () => {
|
|
controls.update();
|
|
renderer.render(scene, camera);
|
|
raf = requestAnimationFrame(tick);
|
|
};
|
|
tick();
|
|
|
|
return () => {
|
|
disposed = true;
|
|
cancelAnimationFrame(raf);
|
|
observer.disconnect();
|
|
controls.dispose();
|
|
scene.traverse((object) => {
|
|
const mesh = object as THREE.Mesh;
|
|
if (mesh.geometry) mesh.geometry.dispose();
|
|
const material = mesh.material;
|
|
if (Array.isArray(material)) material.forEach((m) => m.dispose());
|
|
else if (material) (material as THREE.Material).dispose();
|
|
});
|
|
envTexture.dispose();
|
|
pmrem.dispose();
|
|
renderer.dispose();
|
|
if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement);
|
|
};
|
|
}, [src]);
|
|
|
|
const toggleAutoRotate = () => {
|
|
const next = !autoRotate;
|
|
setAutoRotate(next);
|
|
autoRef.current = next;
|
|
if (controlsRef.current) controlsRef.current.autoRotate = next;
|
|
};
|
|
|
|
const resetView = () => {
|
|
const camera = cameraRef.current;
|
|
const controls = controlsRef.current;
|
|
const home = homeRef.current;
|
|
if (!camera || !controls || !home) return;
|
|
camera.position.copy(home.pos);
|
|
controls.target.copy(home.target);
|
|
controls.update();
|
|
};
|
|
|
|
return (
|
|
<div className="relative h-full w-full overflow-hidden rounded-[14px]">
|
|
<div
|
|
ref={mountRef}
|
|
className="h-full w-full"
|
|
style={{
|
|
background:
|
|
"radial-gradient(circle at 50% 36%, rgb(63 70 54 / 0.55), rgb(18 21 16 / 0.92) 72%)",
|
|
}}
|
|
/>
|
|
|
|
{status === "loading" ? (
|
|
<div className="absolute inset-0 grid place-items-center bg-[rgb(18_21_16_/_0.55)] backdrop-blur-[2px]">
|
|
<div className="flex flex-col items-center gap-3 text-center">
|
|
<span className="grid h-12 w-12 animate-spin place-items-center rounded-full border-2 border-[rgb(200_164_106_/_0.25)] border-t-[var(--primary)]" />
|
|
<p className="text-sm font-semibold text-[var(--tour-ink)]">Đang tải mô hình 3D… {progress > 0 ? `${progress}%` : ""}</p>
|
|
<p className="max-w-[24ch] text-xs text-[var(--muted-foreground)]">Hiện vật độ phân giải cao, vui lòng đợi trong giây lát.</p>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{status === "error" ? (
|
|
<div className="absolute inset-0 grid place-items-center bg-[rgb(18_21_16_/_0.7)]">
|
|
<p className="max-w-[28ch] px-6 text-center text-sm font-semibold text-[var(--tour-ink)]">
|
|
Không tải được mô hình 3D. Vui lòng kiểm tra kết nối và thử lại.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{status === "ready" ? (
|
|
<>
|
|
<div className="pointer-events-none absolute left-1/2 top-4 -translate-x-1/2 rounded-full border border-[rgb(215_199_157_/_0.18)] bg-[rgb(18_21_16_/_0.55)] px-3.5 py-1.5 text-[0.72rem] font-medium text-[var(--tour-ink)] backdrop-blur-md">
|
|
Kéo để xoay · Lăn chuột để phóng to
|
|
</div>
|
|
<div className="absolute bottom-4 right-4 flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={toggleAutoRotate}
|
|
aria-label={autoRotate ? "Tạm dừng tự xoay" : "Bật tự xoay"}
|
|
className="grid h-10 w-10 place-items-center rounded-full border border-[rgb(215_199_157_/_0.22)] bg-[rgb(18_21_16_/_0.6)] text-[var(--tour-ink)] backdrop-blur-md transition hover:border-[var(--primary)] hover:text-[var(--primary)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[rgb(200_164_106_/_0.25)]"
|
|
>
|
|
{autoRotate ? <Pause className="h-4 w-4" strokeWidth={1.8} /> : <Play className="h-4 w-4" strokeWidth={1.8} />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={resetView}
|
|
aria-label="Đặt lại góc nhìn"
|
|
className="grid h-10 w-10 place-items-center rounded-full border border-[rgb(215_199_157_/_0.22)] bg-[rgb(18_21_16_/_0.6)] text-[var(--tour-ink)] backdrop-blur-md transition hover:border-[var(--primary)] hover:text-[var(--primary)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[rgb(200_164_106_/_0.25)]"
|
|
>
|
|
<Maximize2 className="h-4 w-4" strokeWidth={1.8} />
|
|
</button>
|
|
</div>
|
|
<div className="pointer-events-none absolute bottom-4 left-4 inline-flex items-center gap-1.5 text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-[rgb(248_242_229_/_0.62)]">
|
|
<Rotate3d className="h-3.5 w-3.5" strokeWidth={1.8} />
|
|
Mô hình 3D
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|