fix: Fix 3d models

This commit is contained in:
2025-10-02 22:20:37 +03:00
parent a357994025
commit 26e4d70b95
9 changed files with 666 additions and 34 deletions

View File

@@ -1,9 +1,14 @@
import { Box, Button } from "@mui/material";
import { MediaViewer } from "@widgets";
import { PreviewMediaDialog } from "@shared";
import {
PreviewMediaDialog,
filterValidFiles,
getAllAcceptString,
} from "@shared";
import { X, Upload } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react";
import { toast } from "react-toastify";
export const MediaArea = observer(
({
@@ -36,7 +41,15 @@ export const MediaArea = observer(
const files = Array.from(e.dataTransfer.files);
if (files.length && onFilesDrop) {
onFilesDrop(files);
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error) => toast.error(error));
}
if (validFiles.length > 0) {
onFilesDrop(validFiles);
}
}
};
@@ -56,7 +69,15 @@ export const MediaArea = observer(
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length && onFilesDrop) {
onFilesDrop(files);
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error) => toast.error(error));
}
if (validFiles.length > 0) {
onFilesDrop(validFiles);
}
}
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
event.target.value = "";
@@ -68,7 +89,7 @@ export const MediaArea = observer(
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept="image/*,video/*,.glb,.gltf"
accept={getAllAcceptString()}
multiple
style={{ display: "none" }}
/>

View File

@@ -1,8 +1,15 @@
import { Box, Button } from "@mui/material";
import { editSightStore, SelectMediaDialog, UploadMediaDialog } from "@shared";
import {
editSightStore,
SelectMediaDialog,
UploadMediaDialog,
filterValidFiles,
getAllAcceptString,
} from "@shared";
import { Upload } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react";
import { toast } from "react-toastify";
export const MediaAreaForSight = observer(
({
@@ -38,11 +45,18 @@ export const MediaAreaForSight = observer(
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length && onFilesDrop) {
setFileToUpload(files[0]);
}
if (files.length) {
const { validFiles, errors } = filterValidFiles(files);
setUploadMediaDialogOpen(true);
if (errors.length > 0) {
errors.forEach((error: string) => toast.error(error));
}
if (validFiles.length > 0 && onFilesDrop) {
setFileToUpload(validFiles[0]);
setUploadMediaDialogOpen(true);
}
}
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -60,10 +74,18 @@ export const MediaAreaForSight = observer(
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length && onFilesDrop) {
setFileToUpload(files[0]);
onFilesDrop(files);
setUploadMediaDialogOpen(true);
if (files.length) {
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error: string) => toast.error(error));
}
if (validFiles.length > 0 && onFilesDrop) {
setFileToUpload(validFiles[0]);
onFilesDrop(validFiles);
setUploadMediaDialogOpen(true);
}
}
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
@@ -76,7 +98,7 @@ export const MediaAreaForSight = observer(
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept="image/*,video/*,.glb,.gltf"
accept={getAllAcceptString()}
multiple
style={{ display: "none" }}
/>

View File

@@ -1,5 +1,7 @@
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";
type ModelViewerProps = {
width?: string;
@@ -7,21 +9,72 @@ type ModelViewerProps = {
height?: string;
};
const Model = ({ fileUrl }: { fileUrl: string }) => {
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) => {
const { scene } = useGLTF(fileUrl);
// Очищаем кеш при размонтировании
useEffect(() => {
return () => {
useGLTF.clear(fileUrl);
console.log("🧹 ThreeView: Очистка кеша модели", { fileUrl });
};
}, [fileUrl]);
return (
<Canvas style={{ height: height, width: width }}>
<ambientLight />
<directionalLight />
<Stage environment="city" intensity={0.6}>
<primitive object={scene} />
</Stage>
<OrbitControls />
</Canvas>
<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>
);
};

View File

@@ -0,0 +1,263 @@
import React, { Component, ReactNode } from "react";
import { Box, Button, Typography, Paper } from "@mui/material";
import { RefreshCw, AlertTriangle } from "lucide-react";
interface Props {
children: ReactNode;
onReset?: () => void;
resetKey?: number | string;
}
interface State {
hasError: boolean;
error: Error | null;
lastResetKey?: number | string;
}
export class ThreeViewErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
lastResetKey: props.resetKey,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return {
hasError: true,
error,
};
}
static getDerivedStateFromProps(
props: Props,
state: State
): Partial<State> | null {
// Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey)
if (
props.resetKey !== state.lastResetKey &&
state.lastResetKey !== undefined
) {
const oldMediaId = String(state.lastResetKey).split("-")[0];
const newMediaId = String(props.resetKey).split("-")[0];
// Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель)
if (oldMediaId !== newMediaId) {
console.log(
"🔄 ThreeViewErrorBoundary: Сброс ошибки при смене модели",
{
oldKey: state.lastResetKey,
newKey: props.resetKey,
oldMediaId,
newMediaId,
}
);
return {
hasError: false,
error: null,
lastResetKey: props.resetKey,
};
}
// Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey
// но не сбрасываем ошибку автоматически - ждем результата загрузки
console.log(
"🔄 ThreeViewErrorBoundary: Обновление lastResetKey без сброса ошибки",
{
oldKey: state.lastResetKey,
newKey: props.resetKey,
}
);
return {
lastResetKey: props.resetKey,
};
}
if (state.lastResetKey === undefined && props.resetKey !== undefined) {
return {
lastResetKey: props.resetKey,
};
}
return null;
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("❌ ThreeViewErrorBoundary: Ошибка загрузки 3D модели", {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
}
getErrorMessage = () => {
const errorMessage = this.state.error?.message || "";
if (
errorMessage.includes("not valid JSON") ||
errorMessage.includes("Unexpected token")
) {
return "Неверный формат файла. Убедитесь, что файл является корректной 3D моделью в формате GLB/GLTF.";
}
if (errorMessage.includes("Could not load")) {
return "Не удалось загрузить файл 3D модели. Проверьте, что файл существует и доступен.";
}
if (errorMessage.includes("404") || errorMessage.includes("Not Found")) {
return "Файл 3D модели не найден на сервере.";
}
if (errorMessage.includes("Network") || errorMessage.includes("fetch")) {
return "Ошибка сети при загрузке 3D модели. Проверьте интернет-соединение.";
}
return (
errorMessage || "Произошла неизвестная ошибка при загрузке 3D модели"
);
};
getErrorReasons = () => {
const errorMessage = this.state.error?.message || "";
if (
errorMessage.includes("not valid JSON") ||
errorMessage.includes("Unexpected token")
) {
return [
"Файл не является 3D моделью",
"Загружен файл неподдерживаемого формата",
"Файл поврежден или не полностью загружен",
"Используйте только GLB или GLTF форматы",
];
}
return [
"Поврежденный файл модели",
"Неподдерживаемый формат",
"Проблемы с загрузкой файла",
];
};
handleReset = () => {
console.log(
"🔄 ThreeViewErrorBoundary: Перезагрузка компонента и перезапрос модели"
);
// Сначала сбрасываем состояние ошибки
this.setState(
{
hasError: false,
error: null,
},
() => {
// После того как состояние обновилось, вызываем callback для изменения resetKey
// Это приведет к пересозданию компонента и новой попытке загрузки
this.props.onReset?.();
}
);
};
handleClose = () => {
console.log("❌ ThreeViewErrorBoundary: Закрытие ошибки");
this.setState({
hasError: false,
error: null,
});
};
render() {
if (this.state.hasError) {
return (
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
p: 2,
}}
>
<Paper
elevation={3}
sx={{
p: 3,
maxWidth: 500,
width: "100%",
position: "relative",
backgroundColor: "error.light",
color: "error.contrastText",
}}
>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<AlertTriangle size={32} style={{ marginRight: 12 }} />
<Typography variant="h6" component="h2">
Ошибка загрузки 3D модели
</Typography>
</Box>
<Typography variant="body2" sx={{ mb: 2 }}>
{this.getErrorMessage()}
</Typography>
<Typography variant="caption" sx={{ mb: 2, display: "block" }}>
Возможные причины:
<ul style={{ margin: "8px 0", paddingLeft: "20px" }}>
{this.getErrorReasons().map((reason, index) => (
<li key={index}>{reason}</li>
))}
</ul>
</Typography>
{this.state.error?.message && (
<Typography
variant="caption"
sx={{
mb: 2,
display: "block",
fontFamily: "monospace",
backgroundColor: "rgba(0, 0, 0, 0.1)",
p: 1,
borderRadius: 1,
fontSize: "0.7rem",
wordBreak: "break-word",
maxHeight: "100px",
overflow: "auto",
}}
>
{this.state.error.message}
</Typography>
)}
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="contained"
startIcon={<RefreshCw size={16} />}
onClick={() => {
console.log(
"🖱️ ThreeViewErrorBoundary: Клик на кнопку 'Попробовать снова'"
);
this.handleReset();
}}
sx={{
backgroundColor: "error.contrastText",
color: "error.main",
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.9)",
},
}}
>
Попробовать снова
</Button>
</Box>
</Paper>
</Box>
);
}
return this.props.children;
}
}

View File

@@ -1,7 +1,9 @@
import { Box } from "@mui/material";
import { useState } from "react";
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import { ThreeView } from "./ThreeView";
import { ThreeViewErrorBoundary } from "./ThreeViewErrorBoundary";
export interface MediaData {
id: string | number;
@@ -25,6 +27,23 @@ export function MediaViewer({
fullHeight?: boolean;
}>) {
const token = localStorage.getItem("token");
const [resetKey, setResetKey] = useState(0);
const handleReset = () => {
console.log("🔄 MediaViewer: handleReset вызван", {
currentResetKey: resetKey,
mediaId: media?.id,
});
setResetKey((prev) => {
const newKey = prev + 1;
console.log("🔄 MediaViewer: resetKey обновлен", {
oldKey: prev,
newKey,
});
return newKey;
});
};
return (
<Box
className={className}
@@ -108,13 +127,19 @@ export function MediaViewer({
)}
{media?.media_type === 6 && (
<ThreeView
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
height={height ? height : "500px"}
width={width ? width : "500px"}
/>
<ThreeViewErrorBoundary
onReset={handleReset}
resetKey={`${media?.id}-${resetKey}`}
>
<ThreeView
key={`3d-model-${media?.id}-${resetKey}`}
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
height={height ? height : "500px"}
width={width ? width : "500px"}
/>
</ThreeViewErrorBoundary>
)}
</Box>
);