feat: Ferramenta de rotação 90° e trava de memória no Analisador 3D
This commit is contained in:
@@ -6,6 +6,9 @@ import { DefectItem, ColorizationMode } from '@/types/analisador3d';
|
|||||||
import { ViewCube } from './ViewCube';
|
import { ViewCube } from './ViewCube';
|
||||||
import {
|
import {
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
RotateCw,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
Eye,
|
Eye,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
@@ -19,6 +22,8 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
Cpu
|
Cpu
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
interface AnalyzerViewer3DProps {
|
interface AnalyzerViewer3DProps {
|
||||||
modelData: LoadedIFCResult | null;
|
modelData: LoadedIFCResult | null;
|
||||||
@@ -33,6 +38,8 @@ interface AnalyzerViewer3DProps {
|
|||||||
isFullscreen?: boolean;
|
isFullscreen?: boolean;
|
||||||
onToggleFullscreen?: () => void;
|
onToggleFullscreen?: () => void;
|
||||||
theme?: 'dark' | 'light';
|
theme?: 'dark' | 'light';
|
||||||
|
selectedOF?: string;
|
||||||
|
initialRotation?: { x: number; y: number; z: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnalyzerViewer3D: React.FC<AnalyzerViewer3DProps> = ({
|
export const AnalyzerViewer3D: React.FC<AnalyzerViewer3DProps> = ({
|
||||||
@@ -48,7 +55,13 @@ export const AnalyzerViewer3D: React.FC<AnalyzerViewer3DProps> = ({
|
|||||||
isFullscreen = false,
|
isFullscreen = false,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
theme = 'dark',
|
theme = 'dark',
|
||||||
|
selectedOF,
|
||||||
|
initialRotation,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [rotationX, setRotationX] = useState<number>(0);
|
||||||
|
const [rotationY, setRotationY] = useState<number>(0);
|
||||||
|
const [isRotationLocked, setIsRotationLocked] = useState<boolean>(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||||
@@ -192,7 +205,41 @@ export const AnalyzerViewer3D: React.FC<AnalyzerViewer3DProps> = ({
|
|||||||
};
|
};
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
// 2. Carrega o modelo na cena diretamente sem deep clone circular
|
// Centraliza e enquadra perfeitamente o modelo na câmera (Fit View)
|
||||||
|
const fitModelToView = useCallback((targetObj?: THREE.Object3D) => {
|
||||||
|
const obj = targetObj || modelGroupRef.current;
|
||||||
|
if (!obj || !cameraRef.current || !controlsRef.current) return;
|
||||||
|
|
||||||
|
obj.updateMatrixWorld(true);
|
||||||
|
const box = new THREE.Box3().setFromObject(obj);
|
||||||
|
if (box.isEmpty()) return;
|
||||||
|
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
if (maxDim <= 0) return;
|
||||||
|
|
||||||
|
const fov = 45 * (Math.PI / 180);
|
||||||
|
let cameraDistance = Math.abs(maxDim / (2 * Math.tan(fov / 2))) * 1.6;
|
||||||
|
cameraDistance = Math.max(cameraDistance, 5);
|
||||||
|
|
||||||
|
const camera = cameraRef.current;
|
||||||
|
camera.near = Math.max(0.01, maxDim / 500);
|
||||||
|
camera.far = Math.max(2000, maxDim * 20);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
camera.position.set(center.x + cameraDistance * 0.7, center.y + cameraDistance * 0.5, center.z + cameraDistance * 0.7);
|
||||||
|
camera.lookAt(center);
|
||||||
|
|
||||||
|
controlsRef.current.target.copy(center);
|
||||||
|
controlsRef.current.maxDistance = Math.max(1500, maxDim * 10);
|
||||||
|
controlsRef.current.update();
|
||||||
|
|
||||||
|
if (gridRef.current) {
|
||||||
|
gridRef.current.position.set(center.x, box.min.y, center.z);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 2. Carrega o modelo na cena diretamente sem deep clone circular e aplica rotação
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scene = sceneRef.current;
|
const scene = sceneRef.current;
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
@@ -209,32 +256,101 @@ export const AnalyzerViewer3D: React.FC<AnalyzerViewer3DProps> = ({
|
|||||||
modelGroupRef.current = group;
|
modelGroupRef.current = group;
|
||||||
scene.add(group);
|
scene.add(group);
|
||||||
|
|
||||||
// Ajusta piso do grid com base no tamanho do modelo
|
// Recupera rotação memorizada para esta OF (localStorage ou banco de dados)
|
||||||
const bbox = new THREE.Box3().setFromObject(group);
|
const storageKey = selectedOF ? `tracksteel_model_rot_${selectedOF}` : null;
|
||||||
if (!bbox.isEmpty()) {
|
let savedX = 0;
|
||||||
const center = new THREE.Vector3();
|
let savedY = 0;
|
||||||
bbox.getCenter(center);
|
let isLocked = false;
|
||||||
const size = new THREE.Vector3();
|
|
||||||
bbox.getSize(size);
|
|
||||||
|
|
||||||
// Centraliza grupo na origem
|
if (storageKey) {
|
||||||
group.position.x = -center.x;
|
const stored = localStorage.getItem(storageKey);
|
||||||
group.position.y = -bbox.min.y;
|
if (stored) {
|
||||||
group.position.z = -center.z;
|
try {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
if (gridRef.current) {
|
if (typeof parsed.x === 'number') savedX = parsed.x;
|
||||||
gridRef.current.position.y = 0;
|
if (typeof parsed.y === 'number') savedY = parsed.y;
|
||||||
}
|
isLocked = true;
|
||||||
|
} catch (e) {}
|
||||||
// Ajusta câmera
|
|
||||||
const maxDim = Math.max(size.x, size.y, size.z);
|
|
||||||
if (cameraRef.current && controlsRef.current) {
|
|
||||||
cameraRef.current.position.set(maxDim * 1.3, maxDim * 1.1, maxDim * 1.5);
|
|
||||||
controlsRef.current.target.set(0, size.y / 2, 0);
|
|
||||||
controlsRef.current.update();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [modelData]);
|
|
||||||
|
if (!isLocked && initialRotation) {
|
||||||
|
if (typeof initialRotation.x === 'number') savedX = initialRotation.x;
|
||||||
|
if (typeof initialRotation.y === 'number') savedY = initialRotation.y;
|
||||||
|
isLocked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRotationX(savedX);
|
||||||
|
setRotationY(savedY);
|
||||||
|
setIsRotationLocked(isLocked);
|
||||||
|
|
||||||
|
// Aplica rotação inicial no grupo do modelo
|
||||||
|
group.rotation.set(
|
||||||
|
THREE.MathUtils.degToRad(savedX),
|
||||||
|
THREE.MathUtils.degToRad(savedY),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
group.updateMatrixWorld(true);
|
||||||
|
|
||||||
|
fitModelToView(group);
|
||||||
|
}, [modelData, selectedOF, initialRotation, fitModelToView]);
|
||||||
|
|
||||||
|
// Gira o modelo em 90 graus no eixo X (tombamento vertical para alinhar Z-Up do CAD com Y-Up)
|
||||||
|
const handleRotate90X = useCallback(() => {
|
||||||
|
if (!modelGroupRef.current) return;
|
||||||
|
|
||||||
|
const nextX = (rotationX + 90) % 360;
|
||||||
|
setRotationX(nextX);
|
||||||
|
setIsRotationLocked(false);
|
||||||
|
|
||||||
|
modelGroupRef.current.rotation.x = THREE.MathUtils.degToRad(nextX);
|
||||||
|
modelGroupRef.current.rotation.y = THREE.MathUtils.degToRad(rotationY);
|
||||||
|
modelGroupRef.current.updateMatrixWorld(true);
|
||||||
|
|
||||||
|
fitModelToView(modelGroupRef.current);
|
||||||
|
}, [rotationX, rotationY, fitModelToView]);
|
||||||
|
|
||||||
|
// Alterna o bloqueio/gravação da posição ideal de visualização no banco e no navegador
|
||||||
|
const handleToggleLockRotation = useCallback(async () => {
|
||||||
|
if (!selectedOF) {
|
||||||
|
toast({
|
||||||
|
title: 'Aviso',
|
||||||
|
description: 'Selecione uma OF para memorizar a rotação do modelo.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRotationLocked) {
|
||||||
|
// Trava e grava a rotação
|
||||||
|
setIsRotationLocked(true);
|
||||||
|
const rotData = { x: rotationX, y: rotationY, z: 0 };
|
||||||
|
|
||||||
|
// 1. Grava no localStorage para acesso instantâneo
|
||||||
|
localStorage.setItem(`tracksteel_model_rot_${selectedOF}`, JSON.stringify(rotData));
|
||||||
|
|
||||||
|
// 2. Persiste na nuvem/Supabase na tabela ordens_fabricacao
|
||||||
|
try {
|
||||||
|
await supabase
|
||||||
|
.from('ordens_fabricacao' as any)
|
||||||
|
.update({ model_3d_rotation: rotData })
|
||||||
|
.eq('num_of', selectedOF);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao salvar rotação no banco:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Posição Travada e Salva',
|
||||||
|
description: `Posição ideal de visualização (${rotationX}°) gravada com sucesso para a OF ${selectedOF}.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Destrava para permitir novo ajuste
|
||||||
|
setIsRotationLocked(false);
|
||||||
|
toast({
|
||||||
|
title: 'Posição Destravada',
|
||||||
|
description: 'Clique em "Girar 90°" para ajustar e trave no cadeado novamente quando estiver na posição correta.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedOF, isRotationLocked, rotationX, rotationY, toast]);
|
||||||
|
|
||||||
// 3. Aplica Colorização Dinâmica com base no modo selecionado e na peça/defeito ativo
|
// 3. Aplica Colorização Dinâmica com base no modo selecionado e na peça/defeito ativo
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -423,13 +539,63 @@ export const AnalyzerViewer3D: React.FC<AnalyzerViewer3DProps> = ({
|
|||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ViewCube Superior Direito */}
|
{/* Top Right: ViewCube + Widget de Rotação 90° & Cadeado de Memória */}
|
||||||
{cameraRef.current && controlsRef.current && (
|
<div className="absolute top-3 right-3 z-20 flex flex-col items-end gap-2.5 pointer-events-auto">
|
||||||
<ViewCube
|
{cameraRef.current && controlsRef.current && (
|
||||||
onSelectView={handleViewCubeSelect}
|
<ViewCube
|
||||||
mainCameraRef={cameraRef as React.MutableRefObject<THREE.Camera | null>}
|
onSelectView={handleViewCubeSelect}
|
||||||
/>
|
mainCameraRef={cameraRef as React.MutableRefObject<THREE.Camera | null>}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Card de Ajuste e Travamento de Rotação 3D (Tombamento 90° + Cadeado Memorizável) */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 p-1.5 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 pointer-events-auto"
|
||||||
|
style={{ minWidth: '130px' }}
|
||||||
|
>
|
||||||
|
{/* Botão Rotacionar / Tombar 90° */}
|
||||||
|
<button
|
||||||
|
onClick={handleRotate90X}
|
||||||
|
disabled={!modelData}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-xl text-[11px] font-bold transition-all duration-150 active:scale-95 ${
|
||||||
|
modelData
|
||||||
|
? 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-100 cursor-pointer shadow-sm'
|
||||||
|
: 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50'
|
||||||
|
}`}
|
||||||
|
title="Girar o modelo 90° no eixo de tombamento (adequar orientação Z/Y)"
|
||||||
|
>
|
||||||
|
<RotateCw className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||||
|
<span className="whitespace-nowrap">Girar 90°</span>
|
||||||
|
<span className="text-[10px] font-mono font-normal text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-950 px-1 py-0.2 rounded border border-slate-200 dark:border-slate-800">
|
||||||
|
{rotationX}°
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Botão Cadeado: Travar / Gravar ou Destravar */}
|
||||||
|
<button
|
||||||
|
onClick={handleToggleLockRotation}
|
||||||
|
disabled={!modelData}
|
||||||
|
className={`flex items-center justify-center p-1.5 rounded-xl transition-all duration-200 active:scale-95 border ${
|
||||||
|
!modelData
|
||||||
|
? 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 border-transparent cursor-not-allowed opacity-40'
|
||||||
|
: isRotationLocked
|
||||||
|
? 'bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/60 dark:hover:bg-emerald-900/80 dark:text-emerald-300 dark:border-emerald-700/80 shadow-sm cursor-pointer'
|
||||||
|
: 'bg-amber-50 hover:bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-950/50 dark:hover:bg-amber-900/70 dark:text-amber-300 dark:border-amber-700/70 shadow-sm cursor-pointer animate-pulse'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
isRotationLocked
|
||||||
|
? 'Posição TRAVADA e salva para esta OF. Clique para destravar e ajustar.'
|
||||||
|
: 'Posição DESTRAVADA. Clique aqui no cadeado para travar e salvar a posição ideal.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isRotationLocked ? (
|
||||||
|
<Lock className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Unlock className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Barra de Ferramentas Flutuante Superior */}
|
{/* Barra de Ferramentas Flutuante Superior */}
|
||||||
<div className="absolute top-3 left-3 flex items-center gap-1.5 p-1.5 bg-white/95 dark:bg-slate-900/90 backdrop-blur-md border border-slate-200 dark:border-slate-700/80 rounded-lg shadow-md z-20">
|
<div className="absolute top-3 left-3 flex items-center gap-1.5 p-1.5 bg-white/95 dark:bg-slate-900/90 backdrop-blur-md border border-slate-200 dark:border-slate-700/80 rounded-lg shadow-md z-20">
|
||||||
@@ -538,7 +704,7 @@ export const AnalyzerViewer3D: React.FC<AnalyzerViewer3DProps> = ({
|
|||||||
|
|
||||||
{/* Alerta de elemento selecionado flutuante */}
|
{/* Alerta de elemento selecionado flutuante */}
|
||||||
{selectedPieceMark && (
|
{selectedPieceMark && (
|
||||||
<div className="absolute top-3 right-16 px-3 py-1.5 bg-indigo-50 dark:bg-indigo-950/90 border border-indigo-200 dark:border-indigo-700/80 rounded-lg shadow-md text-xs flex items-center gap-2 z-20">
|
<div className="absolute top-3 left-[225px] px-3 py-1.5 bg-indigo-50 dark:bg-indigo-950/90 border border-indigo-200 dark:border-indigo-700/80 rounded-lg shadow-md text-xs flex items-center gap-2 z-20">
|
||||||
<Crosshair className="w-3.5 h-3.5 text-indigo-600 dark:text-indigo-400 animate-spin" style={{ animationDuration: '6s' }} />
|
<Crosshair className="w-3.5 h-3.5 text-indigo-600 dark:text-indigo-400 animate-spin" style={{ animationDuration: '6s' }} />
|
||||||
<span className="text-indigo-900 dark:text-indigo-200">
|
<span className="text-indigo-900 dark:text-indigo-200">
|
||||||
Foco Ativo: <strong className="text-indigo-700 dark:text-white font-mono">{selectedPieceMark}</strong>
|
Foco Ativo: <strong className="text-indigo-700 dark:text-white font-mono">{selectedPieceMark}</strong>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ interface OFOption {
|
|||||||
peso_total?: number;
|
peso_total?: number;
|
||||||
ifc_url?: string | null;
|
ifc_url?: string | null;
|
||||||
ifc_filename?: string | null;
|
ifc_filename?: string | null;
|
||||||
|
model_3d_rotation?: { x: number; y: number; z: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Analisador3D() {
|
export default function Analisador3D() {
|
||||||
@@ -103,7 +104,7 @@ export default function Analisador3D() {
|
|||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('ordens_fabricacao' as any)
|
.from('ordens_fabricacao' as any)
|
||||||
.select('id, num_of, descritivo, peso_total, ifc_url, ifc_filename')
|
.select('id, num_of, descritivo, peso_total, ifc_url, ifc_filename, model_3d_rotation')
|
||||||
.order('num_of', { ascending: false });
|
.order('num_of', { ascending: false });
|
||||||
|
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
@@ -114,6 +115,7 @@ export default function Analisador3D() {
|
|||||||
peso_total: Number(item.peso_total || 0),
|
peso_total: Number(item.peso_total || 0),
|
||||||
ifc_url: item.ifc_url || null,
|
ifc_url: item.ifc_url || null,
|
||||||
ifc_filename: item.ifc_filename || null,
|
ifc_filename: item.ifc_filename || null,
|
||||||
|
model_3d_rotation: item.model_3d_rotation || null,
|
||||||
}));
|
}));
|
||||||
setOfs(list);
|
setOfs(list);
|
||||||
if (list.length > 0 && !selectedOF) {
|
if (list.length > 0 && !selectedOF) {
|
||||||
@@ -797,6 +799,8 @@ export default function Analisador3D() {
|
|||||||
isFullscreen={isFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
|
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
|
||||||
theme={isDarkTheme ? 'dark' : 'light'}
|
theme={isDarkTheme ? 'dark' : 'light'}
|
||||||
|
selectedOF={selectedOF}
|
||||||
|
initialRotation={currentOFObj?.model_3d_rotation || null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user