🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:21:23
This commit is contained in:
+2
-1
@@ -25,7 +25,7 @@ const Index = () => {
|
||||
const { model, addModel, models, maxModels, xrSupported, setXrSupported } = useModelStore();
|
||||
|
||||
// --- Painel de Logs Remotos em Tempo Real ---
|
||||
const [debugLogs, setDebugLogs] = useState<{ level: 'info' | 'warn' | 'error'; message: string; timestamp: string; data?: any }[]>([]);
|
||||
const [debugLogs, setDebugLogs] = useState<{ level: 'info' | 'warn' | 'error'; message: string; timestamp: string; data?: unknown }[]>([]);
|
||||
const [showLogs, setShowLogs] = useState(() => new URLSearchParams(window.location.search).get('viewlogs') === 'true');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -204,6 +204,7 @@ const Index = () => {
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTENSIONS}
|
||||
className="hidden"
|
||||
title="Importar arquivo de modelo 3D"
|
||||
onChange={handleFileUpload} />
|
||||
|
||||
|
||||
|
||||
+91
-44
@@ -1,24 +1,48 @@
|
||||
import { useEffect, useRef, useState, useMemo, Suspense } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { OrbitControls, Text, useGLTF } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Users, MessageSquare, Mic, MicOff, Home, Send, Slider,
|
||||
ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut, Check,
|
||||
Scissors, RotateCw, Maximize2, ShieldAlert, Award
|
||||
Users, MessageSquare, Mic, MicOff, Home, Send,
|
||||
ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut,
|
||||
RotateCw, Award
|
||||
} from 'lucide-react';
|
||||
|
||||
// Interfaces tipadas para evitar any
|
||||
interface MiiPreset {
|
||||
id: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface PresentationState {
|
||||
rotation: number[];
|
||||
scale: number;
|
||||
sectionEnabled: boolean;
|
||||
sectionAxis: string;
|
||||
sectionLevel: number;
|
||||
sectionInvert: boolean;
|
||||
}
|
||||
|
||||
interface PeerData {
|
||||
username: string;
|
||||
avatarId: number;
|
||||
chairId: string;
|
||||
role: string;
|
||||
cargo: string;
|
||||
empresa: string;
|
||||
presence_ref: string;
|
||||
}
|
||||
|
||||
// --- Configurações da Mesa de Reunião ---
|
||||
// Coordenadas das 8 cadeiras posicionadas organizadamente FORA da mesa
|
||||
// A mesa vai de X = -1.9 a 1.9 e Z = -1.1 a 1.1. As cadeiras ficam em Z = -1.45 e Z = 1.45.
|
||||
const CHAIRS = [
|
||||
{ id: '1', name: 'Assento 1', pos: [-1.2, 0.95, -1.45], lookAt: [0, 0.95, 0] },
|
||||
{ id: '2', name: 'Assento 2', pos: [-0.4, 0.95, -1.45], lookAt: [0, 0.95, 0] },
|
||||
@@ -30,10 +54,9 @@ const CHAIRS = [
|
||||
{ id: '8', name: 'Assento 8', pos: [-1.2, 0.95, 1.45], lookAt: [0, 0.95, 0] },
|
||||
];
|
||||
|
||||
// 10 Modelos de Mii com características e cores de cabelo humanas
|
||||
const AVATAR_PRESETS = [
|
||||
const AVATAR_PRESETS: MiiPreset[] = [
|
||||
{ id: 1, name: 'Mii Cyber', desc: 'Cabelo curto preto e óculos vermelhos', color: '#ef4444' },
|
||||
{ id: 2, name: 'Mii Classic', desc: 'Pele média com cabelo castanho clássico', color: '#b45309' },
|
||||
{ id: 2, name: 'Mii Classic', desc: 'Pele média com Cabelo castanho clássico', color: '#b45309' },
|
||||
{ id: 3, name: 'Mii Goldie', desc: 'Cabelo loiro longo com olhos azuis expressivos', color: '#eab308' },
|
||||
{ id: 4, name: 'Mii Sporty', desc: 'Boné azul virado para trás e sorriso aberto', color: '#3b82f6' },
|
||||
{ id: 5, name: 'Mii Ginger', desc: 'Cabelo ruivo longo ondulado e expressivo', color: '#f97316' },
|
||||
@@ -44,16 +67,31 @@ const AVATAR_PRESETS = [
|
||||
{ id: 10, name: 'Mii Straw', desc: 'Chapéu de palha do campo clássico', color: '#ca8a04' },
|
||||
];
|
||||
|
||||
// Cores de pele Mii
|
||||
const SKIN_COLORS = ['#fed7aa', '#fdba74', '#f59e0b', '#d97706', '#9a3412', '#ffedd5'];
|
||||
|
||||
// Classes Tailwind estáticas para evitar inline styles no list view dos avatares
|
||||
const getAvatarBgClass = (avatarId: number): string => {
|
||||
const classes = [
|
||||
'bg-red-500/20 text-red-400',
|
||||
'bg-amber-800/20 text-amber-500',
|
||||
'bg-yellow-500/20 text-yellow-400',
|
||||
'bg-blue-500/20 text-blue-400',
|
||||
'bg-orange-500/20 text-orange-400',
|
||||
'bg-emerald-500/20 text-emerald-400',
|
||||
'bg-amber-900/20 text-amber-600',
|
||||
'bg-slate-700/20 text-slate-400',
|
||||
'bg-purple-500/20 text-purple-400',
|
||||
'bg-yellow-600/20 text-yellow-500'
|
||||
];
|
||||
return classes[(avatarId - 1) % 10] || classes[0];
|
||||
};
|
||||
|
||||
// --- Subcomponentes 3D ---
|
||||
|
||||
// Avatar estilo Mii (Nintendo Wii)
|
||||
function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: number; username: string; isTalking?: boolean } & any) {
|
||||
function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: number; username: string; isTalking?: boolean } & Record<string, unknown>) {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
// Parâmetros do rosto e cabelo com base no ID
|
||||
const mii = useMemo(() => {
|
||||
const skin = SKIN_COLORS[(avatarId - 1) % SKIN_COLORS.length];
|
||||
return {
|
||||
@@ -71,20 +109,34 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
|
||||
}, [avatarId]);
|
||||
|
||||
useFrame((state) => {
|
||||
if (groupRef.current) {
|
||||
groupRef.current.position.y = props.position[1] + Math.sin(state.clock.getElapsedTime() * 2 + avatarId) * 0.012;
|
||||
if (groupRef.current && props.position && Array.isArray(props.position) && props.position.length >= 2) {
|
||||
groupRef.current.position.y = (props.position[1] as number) + Math.sin(state.clock.getElapsedTime() * 2 + avatarId) * 0.012;
|
||||
}
|
||||
});
|
||||
|
||||
const positionVector = useMemo(() => {
|
||||
if (props.position && Array.isArray(props.position)) {
|
||||
return new THREE.Vector3(props.position[0] as number, props.position[1] as number, props.position[2] as number);
|
||||
}
|
||||
return new THREE.Vector3(0, 0, 0);
|
||||
}, [props.position]);
|
||||
|
||||
const rotationEuler = useMemo(() => {
|
||||
if (props.rotation && Array.isArray(props.rotation)) {
|
||||
return new THREE.Euler(props.rotation[0] as number, props.rotation[1] as number, props.rotation[2] as number);
|
||||
}
|
||||
return new THREE.Euler(0, 0, 0);
|
||||
}, [props.rotation]);
|
||||
|
||||
return (
|
||||
<group ref={groupRef} {...props}>
|
||||
<group ref={groupRef} position={positionVector} rotation={rotationEuler}>
|
||||
{/* Balão de Nome acima da cabeça */}
|
||||
<group position={[0, 0.28, 0]}>
|
||||
<mesh position={[0, 0, -0.001]}>
|
||||
<planeGeometry args={[0.22, 0.05]} />
|
||||
<meshBasicMaterial color="#0f172a" transparent opacity={0.7} />
|
||||
</mesh>
|
||||
<Text fontSize={0.024} color="#ffffff" anchorX="center" anchorY="middle" fontStyle="bold">
|
||||
<Text fontSize={0.024} color="#ffffff" anchorX="center" anchorY="middle" fontWeight="bold">
|
||||
{username}
|
||||
</Text>
|
||||
</group>
|
||||
@@ -138,7 +190,7 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
|
||||
<sphereGeometry args={[0.08, 16, 16]} />
|
||||
<meshStandardMaterial color={mii.capColor} roughness={0.5} />
|
||||
</mesh>
|
||||
{/* Aba do Boné (virada para trás) */}
|
||||
{/* Aba do Boné */}
|
||||
<mesh position={[0, 0, -0.08]} rotation={[0.1, 0, 0]}>
|
||||
<boxGeometry args={[0.11, 0.008, 0.08]} />
|
||||
<meshStandardMaterial color={mii.capColor} roughness={0.5} />
|
||||
@@ -197,12 +249,10 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
|
||||
{/* BARBA E BIGODE */}
|
||||
{mii.beard && (
|
||||
<group>
|
||||
{/* Bigode */}
|
||||
<mesh position={[0, 0.075, 0.075]}>
|
||||
<boxGeometry args={[0.04, 0.006, 0.005]} />
|
||||
<meshBasicMaterial color="#713f12" />
|
||||
</mesh>
|
||||
{/* Barbicha */}
|
||||
<mesh position={[0, 0.045, 0.072]} scale={[1.2, 1, 1]}>
|
||||
<sphereGeometry args={[0.015, 8, 8]} />
|
||||
<meshStandardMaterial color="#713f12" roughness={0.8} />
|
||||
@@ -213,17 +263,14 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
|
||||
{/* ÓCULOS */}
|
||||
{mii.hasGlasses && (
|
||||
<group position={[0, 0.11, 0.072]}>
|
||||
{/* Lente esquerda */}
|
||||
<mesh position={[-0.028, 0, 0.002]}>
|
||||
<ringGeometry args={[0.016, 0.019, 16]} />
|
||||
<meshBasicMaterial color={mii.glassesColor} />
|
||||
</mesh>
|
||||
{/* Lente direita */}
|
||||
<mesh position={[0.028, 0, 0.002]}>
|
||||
<ringGeometry args={[0.016, 0.019, 16]} />
|
||||
<meshBasicMaterial color={mii.glassesColor} />
|
||||
</mesh>
|
||||
{/* Ponte */}
|
||||
<mesh position={[0, 0, 0.002]}>
|
||||
<boxGeometry args={[0.028, 0.004, 0.002]} />
|
||||
<meshBasicMaterial color={mii.glassesColor} />
|
||||
@@ -235,8 +282,7 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
|
||||
}
|
||||
|
||||
// Carregador e Escalador Inteligente da Maquete Holográfica
|
||||
// Sincroniza transformações de rotação, escala e planos de corte do Apresentador
|
||||
function HologramModel({ model, presentationState }: { model: { url: string }; presentationState: any }) {
|
||||
function HologramModel({ model, presentationState }: { model: { url: string }; presentationState: PresentationState }) {
|
||||
const { scene } = useGLTF(model.url);
|
||||
const clone = useMemo(() => scene.clone(), [scene]);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
@@ -270,7 +316,6 @@ function HologramModel({ model, presentationState }: { model: { url: string }; p
|
||||
groupRef.current.rotation.z = rotation[2];
|
||||
}
|
||||
if (scale) {
|
||||
// Multiplica a escala base inteligente pelo fator da apresentação
|
||||
const box = new THREE.Box3().setFromObject(clone);
|
||||
const size = new THREE.Vector3();
|
||||
box.getSize(size);
|
||||
@@ -303,13 +348,11 @@ function HologramModel({ model, presentationState }: { model: { url: string }; p
|
||||
return;
|
||||
}
|
||||
|
||||
// Vetor normal baseado no eixo e na inversão do corte
|
||||
const normal = new THREE.Vector3();
|
||||
if (sectionAxis === 'x') normal.set(sectionInvert ? -1 : 1, 0, 0);
|
||||
else if (sectionAxis === 'y') normal.set(0, sectionInvert ? -1 : 1, 0);
|
||||
else if (sectionAxis === 'z') normal.set(0, 0, sectionInvert ? -1 : 1);
|
||||
|
||||
// O plano corta na coordenada desejada
|
||||
const plane = new THREE.Plane(normal, sectionLevel);
|
||||
|
||||
clone.traverse((obj) => {
|
||||
@@ -335,7 +378,7 @@ function HologramModel({ model, presentationState }: { model: { url: string }; p
|
||||
}
|
||||
|
||||
// Cenário da Sala de Reunião Clara
|
||||
function ThreeMeetingRoomScene({ currentActiveModel, presentationState }: { currentActiveModel: any; presentationState: any }) {
|
||||
function ThreeMeetingRoomScene({ currentActiveModel, presentationState }: { currentActiveModel: { url: string; fileName: string } | null; presentationState: PresentationState }) {
|
||||
const tableGeom = useMemo(() => {
|
||||
// Geometria da mesa em U
|
||||
const shape = new THREE.Shape();
|
||||
@@ -471,7 +514,7 @@ function ThreeMeetingRoomScene({ currentActiveModel, presentationState }: { curr
|
||||
))}
|
||||
|
||||
{/* Iluminação Clara da Sala */}
|
||||
<hemisphereLight skyColor="#ffffff" groundColor="#64748b" intensity={1.4} />
|
||||
<hemisphereLight color="#ffffff" groundColor="#64748b" intensity={1.4} />
|
||||
<directionalLight position={[6, 12, 6]} intensity={1.8} castShadow />
|
||||
<directionalLight position={[-6, 8, -6]} intensity={0.5} />
|
||||
<pointLight position={[0, 3.5, 0]} intensity={2.0} color="#0ea5e9" distance={8} />
|
||||
@@ -497,7 +540,7 @@ export default function MeetingRoom() {
|
||||
const [inputRoomId, setInputRoomId] = useState(roomId || '');
|
||||
|
||||
// Estados da Sala
|
||||
const [peers, setPeers] = useState<Record<string, { username: string; avatarId: number; chairId: string; role: string; cargo: string; empresa: string; presence_ref: string }>>({});
|
||||
const [peers, setPeers] = useState<Record<string, PeerData>>({});
|
||||
const [chatMessages, setChatMessages] = useState<{ id: string; sender: string; text: string; time: string }[]>([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
@@ -507,7 +550,7 @@ export default function MeetingRoom() {
|
||||
const [activeTab, setActiveTab] = useState<'chat' | 'participants'>('participants');
|
||||
|
||||
// Estado compartilhado da Apresentação da maquete
|
||||
const [presentationState, setPresentationState] = useState({
|
||||
const [presentationState, setPresentationState] = useState<PresentationState>({
|
||||
rotation: [0, 0, 0],
|
||||
scale: 1,
|
||||
sectionEnabled: false,
|
||||
@@ -516,7 +559,7 @@ export default function MeetingRoom() {
|
||||
sectionInvert: false
|
||||
});
|
||||
|
||||
const channelRef = useRef<any>(null);
|
||||
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
|
||||
const chatScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Se o roomId mudar na url, atualiza o checkbox isPresenter
|
||||
@@ -544,10 +587,10 @@ export default function MeetingRoom() {
|
||||
}, [chatMessages]);
|
||||
|
||||
// Envia as atualizações de apresentação via Broadcast para a sala inteira
|
||||
const handlePresentationChange = (updatedFields: Partial<typeof presentationState>) => {
|
||||
const handlePresentationChange = (updatedFields: Partial<PresentationState>) => {
|
||||
if (!isPresenter || !channelRef.current) return;
|
||||
|
||||
const newState = { ...presentationState, ...updatedFields };
|
||||
const newState = { ...presentationState, ...updatedFields } as PresentationState;
|
||||
setPresentationState(newState);
|
||||
|
||||
channelRef.current.send({
|
||||
@@ -581,11 +624,11 @@ export default function MeetingRoom() {
|
||||
channel
|
||||
.on('presence', { event: 'sync' }, () => {
|
||||
const state = channel.presenceState();
|
||||
const formattedPeers: Record<string, any> = {};
|
||||
const formattedPeers: Record<string, PeerData> = {};
|
||||
let count = 0;
|
||||
|
||||
Object.keys(state).forEach((chairKey) => {
|
||||
const presences = state[chairKey] as any[];
|
||||
const presences = state[chairKey] as unknown as PeerData[];
|
||||
if (presences && presences.length > 0) {
|
||||
formattedPeers[chairKey] = presences[0];
|
||||
count++;
|
||||
@@ -595,10 +638,10 @@ export default function MeetingRoom() {
|
||||
setActiveUsersCount(count);
|
||||
})
|
||||
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
|
||||
toast.info(`"${newPresences[0]?.username}" sentou-se no assento ${key}`);
|
||||
toast.info(`"${(newPresences[0] as unknown as PeerData)?.username}" sentou-se no assento ${key}`);
|
||||
})
|
||||
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
|
||||
toast.info(`"${leftPresences[0]?.username}" liberou o assento ${key}`);
|
||||
toast.info(`"${(leftPresences[0] as unknown as PeerData)?.username}" liberou o assento ${key}`);
|
||||
});
|
||||
|
||||
// Escuta mensagens do Chat via Broadcast
|
||||
@@ -617,7 +660,7 @@ export default function MeetingRoom() {
|
||||
// Escuta atualizações de Apresentação da Maquete via Broadcast (apenas para convidados)
|
||||
channel.on('broadcast', { event: 'presentation_update' }, (payload) => {
|
||||
if (!isPresenter) {
|
||||
setPresentationState(payload.payload);
|
||||
setPresentationState(payload.payload as PresentationState);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -886,7 +929,7 @@ export default function MeetingRoom() {
|
||||
<span className="font-mono text-xs font-bold text-primary flex items-center gap-1.5">
|
||||
<Award className="h-4 w-4 animate-pulse" /> Painel do Apresentador
|
||||
</span>
|
||||
<span className="font-mono text-[8px] bg-emerald-500/25 text-emerald-400 px-1.5 py-0.5 rounded uppercase">Sincronizado</span>
|
||||
<span className="font-mono text-[8px] bg-emerald-500/25 text-emerald-400 px-1.5 py-0.5 rounded uppercase font-semibold">Sincronizado</span>
|
||||
</div>
|
||||
|
||||
{/* Rotação Y */}
|
||||
@@ -900,6 +943,7 @@ export default function MeetingRoom() {
|
||||
min={-Math.PI}
|
||||
max={Math.PI}
|
||||
step={0.05}
|
||||
title="Giro Horizontal"
|
||||
value={presentationState.rotation[1]}
|
||||
onChange={(e) => handlePresentationChange({ rotation: [0, parseFloat(e.target.value), 0] })}
|
||||
className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
@@ -917,6 +961,7 @@ export default function MeetingRoom() {
|
||||
min={0.3}
|
||||
max={3.0}
|
||||
step={0.1}
|
||||
title="Zoom da Peça"
|
||||
value={presentationState.scale}
|
||||
onChange={(e) => handlePresentationChange({ scale: parseFloat(e.target.value) })}
|
||||
className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
@@ -930,6 +975,7 @@ export default function MeetingRoom() {
|
||||
<input
|
||||
type="checkbox"
|
||||
id="section-cuts-enable"
|
||||
title="Habilitar Plano de Corte"
|
||||
checked={presentationState.sectionEnabled}
|
||||
onChange={(e) => handlePresentationChange({ sectionEnabled: e.target.checked })}
|
||||
className="rounded border-slate-800 bg-slate-950 accent-primary cursor-pointer h-3.5 w-3.5"
|
||||
@@ -964,6 +1010,7 @@ export default function MeetingRoom() {
|
||||
min={-0.5}
|
||||
max={0.5}
|
||||
step={0.01}
|
||||
title="Nível de Corte"
|
||||
value={presentationState.sectionLevel}
|
||||
onChange={(e) => handlePresentationChange({ sectionLevel: parseFloat(e.target.value) })}
|
||||
className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
@@ -978,6 +1025,7 @@ export default function MeetingRoom() {
|
||||
<input
|
||||
type="checkbox"
|
||||
id="invert-section"
|
||||
title="Inverter Plano de Corte"
|
||||
checked={presentationState.sectionInvert}
|
||||
onChange={(e) => handlePresentationChange({ sectionInvert: e.target.checked })}
|
||||
className="rounded border-slate-800 bg-slate-950 accent-primary cursor-pointer h-3 w-3"
|
||||
@@ -1102,13 +1150,12 @@ export default function MeetingRoom() {
|
||||
{Object.keys(peers).map((chairKey) => {
|
||||
const peer = peers[chairKey];
|
||||
const isHost = peer.role === 'presenter';
|
||||
const avatar = AVATAR_PRESETS[peer.avatarId - 1] || AVATAR_PRESETS[0];
|
||||
const isMe = peer.chairId === selectedChair;
|
||||
|
||||
return (
|
||||
<div key={peer.presence_ref} className="rounded-lg border border-slate-800 bg-slate-950/40 p-3 flex items-start gap-3 relative overflow-hidden">
|
||||
{/* Indicador da cor do avatar do Mii */}
|
||||
<div className="h-8 w-8 rounded-full border border-slate-800 flex items-center justify-center font-bold text-xs" style={{ backgroundColor: `${avatar.color}35`, color: avatar.color }}>
|
||||
{/* Indicador da cor do avatar do Mii via classe estática Tailwind */}
|
||||
<div className={`h-8 w-8 rounded-full border border-slate-800 flex items-center justify-center font-bold text-xs ${getAvatarBgClass(peer.avatarId)}`}>
|
||||
{peer.username.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user