155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
import { Canvas } from "@react-three/fiber";
|
||
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
||
import { useEffect, Suspense } from "react";
|
||
import { Box, CircularProgress, Typography } from "@mui/material";
|
||
|
||
// Утилита для очистки кеша GLTF
|
||
const clearGLTFCache = (url?: string) => {
|
||
try {
|
||
if (url) {
|
||
// Если это blob URL, очищаем его из кеша
|
||
if (url.startsWith("blob:")) {
|
||
useGLTF.clear(url);
|
||
} else {
|
||
useGLTF.clear(url);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn("⚠️ clearGLTFCache: Ошибка при очистке кеша", error);
|
||
}
|
||
};
|
||
|
||
// Утилита для проверки типа файла
|
||
const isValid3DFile = (url: string): boolean => {
|
||
try {
|
||
const urlObj = new URL(url);
|
||
const pathname = urlObj.pathname.toLowerCase();
|
||
const searchParams = urlObj.searchParams;
|
||
|
||
// Проверяем расширение файла в пути
|
||
const validExtensions = [".glb", ".gltf"];
|
||
const hasValidExtension = validExtensions.some((ext) =>
|
||
pathname.endsWith(ext)
|
||
);
|
||
|
||
// Проверяем параметры запроса на наличие типа файла
|
||
const fileType = searchParams.get("type") || searchParams.get("format");
|
||
const hasValidType =
|
||
fileType && ["glb", "gltf"].includes(fileType.toLowerCase());
|
||
|
||
// Если это blob URL, считаем его валидным (пользователь выбрал файл)
|
||
const isBlobUrl = url.startsWith("blob:");
|
||
|
||
// Если это URL с токеном и нет явного расширения, считаем валидным
|
||
// (предполагаем что сервер вернет правильный файл)
|
||
const hasToken = searchParams.has("token");
|
||
const isServerUrl = hasToken && !hasValidExtension;
|
||
|
||
const isValid =
|
||
hasValidExtension || hasValidType || isBlobUrl || isServerUrl;
|
||
|
||
return isValid;
|
||
} catch (error) {
|
||
console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error);
|
||
// В случае ошибки парсинга URL, считаем валидным (пусть useGLTF сам разберется)
|
||
return true;
|
||
}
|
||
};
|
||
|
||
type ModelViewerProps = {
|
||
width?: string;
|
||
fileUrl: string;
|
||
height?: string;
|
||
};
|
||
|
||
const Model = ({ fileUrl }: { fileUrl: string }) => {
|
||
// Очищаем кеш перед загрузкой новой модели
|
||
useEffect(() => {
|
||
// Очищаем кеш для текущего URL
|
||
clearGLTFCache(fileUrl);
|
||
}, [fileUrl]);
|
||
|
||
// Проверяем валидность файла перед загрузкой (только для blob URL)
|
||
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
|
||
console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl });
|
||
throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`);
|
||
}
|
||
|
||
const { scene } = useGLTF(fileUrl);
|
||
return <primitive object={scene} />;
|
||
};
|
||
|
||
const LoadingFallback = () => {
|
||
return (
|
||
<Box
|
||
sx={{
|
||
position: "absolute",
|
||
top: "50%",
|
||
left: "50%",
|
||
transform: "translate(-50%, -50%)",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
gap: 2,
|
||
zIndex: 1000,
|
||
backgroundColor: "background.paper",
|
||
p: 3,
|
||
borderRadius: 2,
|
||
}}
|
||
>
|
||
<CircularProgress size={48} />
|
||
<Typography
|
||
variant="body2"
|
||
color="text.secondary"
|
||
style={{ whiteSpace: "nowrap" }}
|
||
>
|
||
Загрузка 3D модели...
|
||
</Typography>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
export const ThreeView = ({
|
||
fileUrl,
|
||
height = "100%",
|
||
width = "100%",
|
||
}: ModelViewerProps) => {
|
||
// Проверяем валидность файла (только для blob URL)
|
||
useEffect(() => {
|
||
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
|
||
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
|
||
}
|
||
}, [fileUrl]);
|
||
|
||
// Очищаем кеш при размонтировании и при смене URL
|
||
useEffect(() => {
|
||
// Очищаем кеш сразу при монтировании компонента
|
||
clearGLTFCache(fileUrl);
|
||
|
||
return () => {
|
||
clearGLTFCache(fileUrl);
|
||
};
|
||
}, [fileUrl]);
|
||
|
||
return (
|
||
<Box sx={{ position: "relative", width, height }}>
|
||
<Suspense fallback={<LoadingFallback />}>
|
||
<Canvas
|
||
style={{ height: height, width: width }}
|
||
camera={{
|
||
position: [1, 1, 1],
|
||
fov: 30,
|
||
}}
|
||
>
|
||
<ambientLight />
|
||
<directionalLight />
|
||
<Stage environment="city" intensity={0.6} adjustCamera={false}>
|
||
<Model fileUrl={fileUrl} />
|
||
</Stage>
|
||
<OrbitControls />
|
||
</Canvas>
|
||
</Suspense>
|
||
</Box>
|
||
);
|
||
};
|