diff --git a/.lovable/plan.md b/.lovable/plan.md index 46d731b..7fed05f 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -1,42 +1,47 @@ - ## Objetivo -Adicionar alternância entre câmera **Perspectiva** e **Ortográfica** no viewer principal, com **Ortográfica como padrão**, e tornar a calibração mais confiável usando a vista ortogonal (sem distorção de perspectiva, normais alinhadas com os eixos da tela). +Adicionar uma ferramenta de **corte de seção** (clipping planes) nos eixos X, Y e Z — padrão em viewers IFC/BIM. O usuário move sliders por eixo e a peça vai sendo cortada/revelada em tempo real. Implementada como **janela pop-up flutuante** sobre o viewer, com controle de opacidade da própria janela. Sem presets persistidos. -## Por que isso resolve a calibração +## UX -Em perspectiva, ao clicar numa face "de frente", a normal capturada pelo raycaster é a normal real do triângulo, mas o usuário tende a clicar em faces inclinadas achando que estão alinhadas — o que gera pares de vetores ruins. Em **ortográfica + vista alinhada ao cubo (FRENTE/TOPO/DIR)**, a face que aparece "de frente" é geometricamente perpendicular à câmera, então a chance de capturar a normal certa é muito maior. Também damos uma dica visual (snap angular) para validar o clique. +### Botões na barra `ViewerControls` +- **"Cortes"** (ícone `Scissors`) — abre/fecha a janela pop-up. O estado dos cortes é independente da janela: fechar não reseta nada. +- **Lixeira** ao lado, só fica habilitada quando há pelo menos um eixo de corte ativo. Clicar = `resetSection()` (desliga e zera os 3 eixos). Único caminho para zerar. +- Indicador visual no botão "Cortes" quando há corte ativo (badge ou variante `default`). -## Mudanças +### Pop-up (componente `SectionCutPanel`) +- Janela flutuante sobre o viewer (canto superior-direito da área 3D), arrastável pelo header. Não-modal. +- Estilo: `bg-card/X` + `backdrop-blur`, borda industrial, font-mono. +- **Header**: título "Cortes de Seção", slider de **opacidade da janela** (20%–100%, default 90%), botão fechar (X). +- **3 blocos idênticos** para X / Y / Z, cada um com controle individual: + - Switch on/off do eixo. + - Switch "Inverter" (flip do lado mantido). + - Slider do nível de corte mapeado ao bbox mundial da peça ativa naquele eixo, valor em mm. + - Mini-botões: **Min** · **Centro** · **Max** para saltar a posição. +- Botão **"Recalcular limites"** (re-amostra o bbox depois de calibração/fine-tuning). +- Sem botões de salvar/memória. +- Fechar a janela apenas oculta os controles. Reabrir mostra exatamente o estado dos cortes que o usuário deixou. -### 1. Toggle de câmera no viewer (`ModelViewer.tsx` + `ViewerControls.tsx`) -- Estado `cameraMode: 'ortho' | 'persp'` no `useModelStore` (default `'ortho'`, persistido em localStorage junto com as demais prefs visuais). -- Em `ModelViewer.tsx`, condicionar `` / `` (drei) como `makeDefault`, compartilhando posição/target com o `OrbitControls`. Ao trocar de modo, copiar `position`, `target` e calcular `zoom` ortográfico a partir da distância atual para evitar "pulo" visual. -- `OrbitControls` permanece, apenas com `enableRotate` igual; em ortho mantemos rotação livre (estilo CAD). Pan/zoom continuam funcionando. -- Botão de alternância adicionado em `ViewerControls.tsx` ao lado dos controles de render (Sólido / Bordas), com ícones (ex.: `Box` para perspectiva, `Square` para ortho) e tooltip. Atalho de teclado opcional `O`/`P`. +## Comportamento técnico -### 2. ViewCube já existente -- Continua funcionando igual, mas agora ao clicar numa face em modo ortográfico o resultado é uma vista verdadeiramente axial (sem foreshortening), o que é o comportamento esperado de CAD. - -### 3. Calibração refinada (`ViewCube.tsx` + `viewCubeBus.ts`) -- Ao iniciar a calibração, **forçar modo ortográfico** automaticamente (salvando o modo anterior para restaurar ao concluir/cancelar). -- Em cada passo "await-cube-N", após o usuário clicar a face do cubo, **animar a câmera principal** para essa vista (já temos `requestView`) — assim a próxima clique na peça é feito olhando a face de frente. -- Adicionar **snap de normal**: ao capturar a normal da face da peça, se ela estiver a <10° de um eixo principal (X/Y/Z), arredondar para esse eixo. Isso elimina ruído de faces ligeiramente chanfradas. -- Mostrar no hint: "olhando para FRENTE — clique na face frontal da peça" (texto dinâmico do passo). -- Manter o fluxo de 4 cliques + verificação opcional já aprovado. - -### 4. Persistência -- `cameraMode` salvo globalmente (preferência do app, não por peça). -- `calibrationQuat` por peça continua como já está. +- `gl.localClippingEnabled = true` no `onCreated` do ``. +- Novos campos em `useModelStore` (não persistidos): + - `sectionEnabled: { x: boolean; y: boolean; z: boolean }` + - `sectionInvert: { x: boolean; y: boolean; z: boolean }` + - `sectionLevel: { x: number; y: number; z: number }` (em metros, mundo) + - `sectionPanelOpen: boolean` + - `sectionPanelOpacity: number` (0.2–1) + - Setters por eixo + `resetSection()` (zera enabled, invert e leva level ao centro do bbox). + - Helper `hasActiveSection()` derivado de `sectionEnabled`. +- Novo componente `` montado dentro do ``: + - Mantém 3 `THREE.Plane` reutilizáveis. A cada mudança nos campos do store, atualiza `plane.normal` (com sinal flipado se `invert`) e `plane.constant = -level` (axis-aligned, mundo). + - Aplica `material.clippingPlanes = ativos` em cada `Mesh` dos grupos registrados em `modelTransforms`. Restaura `[]` ao desligar todos os eixos. +- Limites dos sliders: bbox mundial agregado dos `getAllModelLocalGroups()`. Calculado quando o painel abre e quando o usuário aciona "Recalcular limites". ## Arquivos afetados -- `src/stores/useModelStore.ts` — campo `cameraMode` + setter. -- `src/components/three/ModelViewer.tsx` — alternância de câmera, sincronização de posição/zoom. -- `src/components/ViewerControls.tsx` — botão de alternância Persp/Ortho. -- `src/components/ViewCube.tsx` — força ortho durante calibração, anima vista após cada cube-click. -- `src/components/three/viewCubeBus.ts` — snap angular da normal capturada. - -## Pergunta - -Te parece bom seguir com **ortográfica como default** e **forçar ortho durante calibração** com snap automático nas normais? Ou prefere deixar o usuário trocar manualmente e a calibração apenas "sugerir" o modo ortho via toast? +- `src/stores/useModelStore.ts` — novos campos e setters de seção + painel. +- `src/components/three/ModelViewer.tsx` — `localClippingEnabled` no GL; monta ``. +- `src/components/SectionCutPanel.tsx` (novo) — janela pop-up flutuante com 3 sliders, switches, opacidade. +- `src/components/ViewerControls.tsx` — botão "Cortes" + lixeira condicional. +- `src/pages/Viewer.tsx` — monta `` na área do viewer. diff --git a/src/components/SectionCutPanel.tsx b/src/components/SectionCutPanel.tsx new file mode 100644 index 0000000..7cf60cb --- /dev/null +++ b/src/components/SectionCutPanel.tsx @@ -0,0 +1,222 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { X, RefreshCw, Scissors } from 'lucide-react'; +import * as THREE from 'three'; +import { Button } from '@/components/ui/button'; +import { Slider } from '@/components/ui/slider'; +import { Switch } from '@/components/ui/switch'; +import { useModelStore } from '@/stores/useModelStore'; +import { getAllModelLocalGroups } from '@/lib/modelTransforms'; + +type Axis = 'x' | 'y' | 'z'; + +interface AxisRange { min: number; max: number; } + +function computeBoundsByAxis(): Record | null { + const groups = getAllModelLocalGroups(); + if (groups.length === 0) return null; + const box = new THREE.Box3(); + let has = false; + for (const g of groups) { + g.updateWorldMatrix(true, true); + const b = new THREE.Box3().setFromObject(g); + if (Number.isFinite(b.min.x) && Number.isFinite(b.max.x)) { + if (!has) { box.copy(b); has = true; } else box.union(b); + } + } + if (!has) return null; + return { + x: { min: box.min.x, max: box.max.x }, + y: { min: box.min.y, max: box.max.y }, + z: { min: box.min.z, max: box.max.z }, + }; +} + +const AXIS_COLOR: Record = { + x: 'text-red-400', + y: 'text-emerald-400', + z: 'text-sky-400', +}; + +const AXIS_LABEL: Record = { x: 'X', y: 'Y', z: 'Z' }; + +export function SectionCutPanel() { + const open = useModelStore((s) => s.sectionPanelOpen); + const opacity = useModelStore((s) => s.sectionPanelOpacity); + const setOpen = useModelStore((s) => s.setSectionPanelOpen); + const setOpacity = useModelStore((s) => s.setSectionPanelOpacity); + + const enabled = useModelStore((s) => s.sectionEnabled); + const invert = useModelStore((s) => s.sectionInvert); + const level = useModelStore((s) => s.sectionLevel); + const setSectionEnabled = useModelStore((s) => s.setSectionEnabled); + const setSectionInvert = useModelStore((s) => s.setSectionInvert); + const setSectionLevel = useModelStore((s) => s.setSectionLevel); + + const activeModelId = useModelStore((s) => s.activeModelId); + + const [bounds, setBounds] = useState | null>(null); + + // Compute bounds when the panel opens or active model changes. + useEffect(() => { + if (!open) return; + // Defer one tick to let scene finish updating world matrices. + const id = setTimeout(() => setBounds(computeBoundsByAxis()), 50); + return () => clearTimeout(id); + }, [open, activeModelId]); + + // Drag-to-move logic. + const [pos, setPos] = useState<{ x: number; y: number }>({ x: 16, y: 16 }); + const dragRef = useRef<{ ox: number; oy: number; sx: number; sy: number } | null>(null); + useEffect(() => { + const onMove = (e: PointerEvent) => { + if (!dragRef.current) return; + setPos({ + x: Math.max(8, dragRef.current.sx + (e.clientX - dragRef.current.ox)), + y: Math.max(8, dragRef.current.sy + (e.clientY - dragRef.current.oy)), + }); + }; + const onUp = () => { dragRef.current = null; }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + return () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + }, []); + + const recompute = () => setBounds(computeBoundsByAxis()); + + if (!open) return null; + + return ( +
+ {/* Header (drag handle) */} +
{ + dragRef.current = { ox: e.clientX, oy: e.clientY, sx: pos.x, sy: pos.y }; + }} + > +
+ + + Cortes de Seção + +
+ +
+ + {/* Window opacity */} +
+
+ + Opacidade da janela + + {Math.round(opacity * 100)}% +
+ setOpacity(v / 100)} + /> +
+ + {/* Per-axis controls */} +
+ {(['x', 'y', 'z'] as Axis[]).map((axis) => { + const range = bounds?.[axis]; + const minMM = range ? range.min * 1000 : 0; + const maxMM = range ? range.max * 1000 : 100; + const valMM = level[axis] * 1000; + const disabled = !range; + return ( +
+
+
+ + {AXIS_LABEL[axis]} + + setSectionEnabled(axis, c)} + disabled={disabled} + /> +
+ +
+ +
+ + {disabled ? '— sem peça' : `${minMM.toFixed(0)} … ${maxMM.toFixed(0)} mm`} + + + {disabled ? '—' : `${valMM.toFixed(1)} mm`} + +
+ + setSectionLevel(axis, v / 1000)} + disabled={disabled || !enabled[axis]} + /> + +
+ + + +
+
+ ); + })} +
+ +
+ +
+
+ ); +} diff --git a/src/components/ViewerControls.tsx b/src/components/ViewerControls.tsx index 741304d..25a5682 100644 --- a/src/components/ViewerControls.tsx +++ b/src/components/ViewerControls.tsx @@ -1,4 +1,4 @@ -import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Square } from 'lucide-react'; +import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Square, Scissors } from 'lucide-react'; import { Slider } from '@/components/ui/slider'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -37,7 +37,9 @@ export function ViewerControls() { hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection, cameraMode, setCameraMode, gridCalibMode, setGridCalibMode, setGridAutoFollow, + sectionPanelOpen, setSectionPanelOpen, sectionEnabled, resetSection, } = useModelStore(); + const sectionActive = sectionEnabled.x || sectionEnabled.y || sectionEnabled.z; const activeModel = models.find(m => m.id === activeModelId); const hasSelection = selectedElementKeys.size > 0; const hasHiddenOrIsolated = hiddenElementKeys.size > 0 || isolatedElementKeys !== null; @@ -131,9 +133,38 @@ export function ViewerControls() { + {/* Section cuts (X/Y/Z clipping planes) */} +
+ + {sectionActive && ( + + )} +
+ {/* Scale presets */} +