Adicionou cortes de seção

X-Lovable-Edit-ID: edt-e77adaba-a2c2-4436-843c-ec9c5c701de0
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-24 22:55:09 +00:00
6 changed files with 392 additions and 33 deletions
+37 -32
View File
@@ -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 `<PerspectiveCamera>` / `<OrthographicCamera>` (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 `<Canvas>`.
- 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.21)
- Setters por eixo + `resetSection()` (zera enabled, invert e leva level ao centro do bbox).
- Helper `hasActiveSection()` derivado de `sectionEnabled`.
- Novo componente `<SectionClippingApplier>` montado dentro do `<Canvas>`:
- 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 `<SectionClippingApplier>`.
- `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 `<SectionCutPanel />` na área do viewer.
+222
View File
@@ -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<Axis, AxisRange> | 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<Axis, string> = {
x: 'text-red-400',
y: 'text-emerald-400',
z: 'text-sky-400',
};
const AXIS_LABEL: Record<Axis, string> = { 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<Record<Axis, AxisRange> | 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 (
<div
className="absolute z-30 w-[320px] rounded-lg border bg-card shadow-2xl backdrop-blur-md"
style={{ right: pos.x, top: pos.y, opacity }}
>
{/* Header (drag handle) */}
<div
className="flex cursor-move items-center justify-between gap-2 border-b px-3 py-2 select-none"
onPointerDown={(e) => {
dragRef.current = { ox: e.clientX, oy: e.clientY, sx: pos.x, sy: pos.y };
}}
>
<div className="flex items-center gap-2">
<Scissors className="h-3.5 w-3.5 text-primary" />
<span className="font-mono text-xs font-semibold uppercase tracking-widest text-foreground">
Cortes de Seção
</span>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setOpen(false)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
{/* Window opacity */}
<div className="border-b px-3 py-2">
<div className="mb-1 flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Opacidade da janela
</span>
<span className="font-mono text-[10px] text-foreground">{Math.round(opacity * 100)}%</span>
</div>
<Slider
value={[Math.round(opacity * 100)]}
min={20}
max={100}
step={5}
onValueChange={([v]) => setOpacity(v / 100)}
/>
</div>
{/* Per-axis controls */}
<div className="space-y-3 px-3 py-3">
{(['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 (
<div key={axis} className="rounded-md border bg-muted/30 p-2.5">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`font-mono text-sm font-bold ${AXIS_COLOR[axis]}`}>
{AXIS_LABEL[axis]}
</span>
<Switch
checked={enabled[axis]}
onCheckedChange={(c) => setSectionEnabled(axis, c)}
disabled={disabled}
/>
</div>
<label className="flex items-center gap-1.5">
<span className="font-mono text-[10px] text-muted-foreground">Inverter</span>
<Switch
checked={invert[axis]}
onCheckedChange={(c) => setSectionInvert(axis, c)}
disabled={disabled || !enabled[axis]}
/>
</label>
</div>
<div className="mb-1 flex items-center justify-between">
<span className="font-mono text-[10px] text-muted-foreground">
{disabled ? '— sem peça' : `${minMM.toFixed(0)}${maxMM.toFixed(0)} mm`}
</span>
<span className="font-mono text-[10px] text-foreground">
{disabled ? '—' : `${valMM.toFixed(1)} mm`}
</span>
</div>
<Slider
value={[range ? Math.min(maxMM, Math.max(minMM, valMM)) : 0]}
min={minMM}
max={maxMM}
step={Math.max(0.1, (maxMM - minMM) / 1000)}
onValueChange={([v]) => setSectionLevel(axis, v / 1000)}
disabled={disabled || !enabled[axis]}
/>
<div className="mt-2 grid grid-cols-3 gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 font-mono text-[10px]"
disabled={disabled || !enabled[axis]}
onClick={() => range && setSectionLevel(axis, range.min)}
>
Min
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 font-mono text-[10px]"
disabled={disabled || !enabled[axis]}
onClick={() => range && setSectionLevel(axis, (range.min + range.max) / 2)}
>
Centro
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 font-mono text-[10px]"
disabled={disabled || !enabled[axis]}
onClick={() => range && setSectionLevel(axis, range.max)}
>
Max
</Button>
</div>
</div>
);
})}
</div>
<div className="border-t px-3 py-2">
<Button variant="outline" size="sm" className="w-full gap-2 h-8" onClick={recompute}>
<RefreshCw className="h-3.5 w-3.5" />
<span className="font-mono text-[11px]">Recalcular limites</span>
</Button>
</div>
</div>
);
}
+32 -1
View File
@@ -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() {
</span>
</Button>
{/* Section cuts (X/Y/Z clipping planes) */}
<div className="flex items-end gap-1.5">
<Button
variant={sectionPanelOpen || sectionActive ? 'default' : 'outline'}
size="sm"
className="gap-2 h-9"
onClick={() => setSectionPanelOpen(!sectionPanelOpen)}
disabled={!activeModelId}
title="Cortes de seção em X / Y / Z"
>
<Scissors className="h-3.5 w-3.5" />
<span className="font-mono text-xs">
Cortes{sectionActive ? ' •' : ''}
</span>
</Button>
{sectionActive && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => { resetSection(); toast.success('Cortes resetados'); }}
title="Resetar todos os cortes"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
)}
</div>
{/* Scale presets */}
<ScaleSelector />
<Button
variant={renderMode === 'edges' ? 'default' : 'outline'}
size="sm"
+68
View File
@@ -1115,6 +1115,72 @@ function GridCalibrationHandler() {
return null;
}
/** Applies axis-aligned clipping planes (X / Y / Z) to every mesh inside the
* registered model groups. Listens to the section state in the store; when
* all axes are off, restores empty clipping arrays so meshes render whole. */
function SectionClippingApplier() {
const enabled = useModelStore((s) => s.sectionEnabled);
const invert = useModelStore((s) => s.sectionInvert);
const level = useModelStore((s) => s.sectionLevel);
const planes = useMemo(() => ({
x: new THREE.Plane(new THREE.Vector3(1, 0, 0), 0),
y: new THREE.Plane(new THREE.Vector3(0, 1, 0), 0),
z: new THREE.Plane(new THREE.Vector3(0, 0, 1), 0),
}), []);
useEffect(() => {
// Configure each plane: keep the half-space on the positive side of the
// normal. Inverting flips the normal direction.
(['x', 'y', 'z'] as const).forEach((axis) => {
const plane = planes[axis];
const sign = invert[axis] ? -1 : 1;
plane.normal.set(
axis === 'x' ? sign : 0,
axis === 'y' ? sign : 0,
axis === 'z' ? sign : 0,
);
// For plane equation n·p + d = 0, keeping `n·p >= level*sign_axis` means
// d = -level when normal is +axis, d = +level when normal is -axis.
plane.constant = -sign * level[axis];
});
const active: THREE.Plane[] = [];
if (enabled.x) active.push(planes.x);
if (enabled.y) active.push(planes.y);
if (enabled.z) active.push(planes.z);
const groups = getAllModelLocalGroups();
const touched: THREE.Material[] = [];
const apply = (clipping: THREE.Plane[]) => {
for (const g of groups) {
g.traverse((obj) => {
if (!(obj instanceof THREE.Mesh)) return;
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of mats) {
if (!mat) continue;
mat.clippingPlanes = clipping;
mat.clipShadows = true;
mat.needsUpdate = true;
touched.push(mat);
}
});
}
};
apply(active);
return () => {
// Cleanup: clear clipping from any material we touched.
for (const mat of touched) {
mat.clippingPlanes = [];
mat.needsUpdate = true;
}
};
}, [enabled.x, enabled.y, enabled.z, invert.x, invert.y, invert.z, level.x, level.y, level.z, planes]);
return null;
}
/** Mouse drag handler for desktop "Posicionar" mode: translates/rotates the active model. */
function PositionDragHandler() {
const { camera, gl, scene } = useThree();
@@ -1283,6 +1349,7 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
const isApple = /Mac|iPhone|iPad/.test(navigator.platform);
const maxRatio = isApple ? 2 : 1.5;
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
gl.localClippingEnabled = true;
}}
>
<CameraSwitcher />
@@ -1302,6 +1369,7 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
<GridLayer />
<GridAutoFollower />
<GridCalibrationHandler />
<SectionClippingApplier />
<PositionDragHandler />
<SelectionHandler />
+2
View File
@@ -9,6 +9,7 @@ import { FineTuningControls } from "@/components/FineTuningControls";
import { MeasurementsList } from "@/components/MeasurementsList";
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
import { ViewCube } from "@/components/ViewCube";
import { SectionCutPanel } from "@/components/SectionCutPanel";
import { ShareButton } from "@/components/ShareButton";
import { CloudLoader } from "@/components/CloudLoader";
import { SceneModelList } from "@/components/SceneModelList";
@@ -141,6 +142,7 @@ const Viewer = () => {
<ModelViewerCanvas />
<ViewCube />
<ViewerControls />
<SectionCutPanel />
</div>
{/* Side panel */}
+31
View File
@@ -261,6 +261,21 @@ interface ModelStore {
gridCalibMode: boolean;
setGridCalibMode: (b: boolean) => void;
/** Section/clipping cut tool (X/Y/Z axis-aligned planes in world space). */
sectionEnabled: { x: boolean; y: boolean; z: boolean };
sectionInvert: { x: boolean; y: boolean; z: boolean };
/** Cut level in meters (world coordinates) per axis. */
sectionLevel: { x: number; y: number; z: number };
sectionPanelOpen: boolean;
/** Opacity of the floating section panel (0.2 1). */
sectionPanelOpacity: number;
setSectionEnabled: (axis: 'x' | 'y' | 'z', on: boolean) => void;
setSectionInvert: (axis: 'x' | 'y' | 'z', on: boolean) => void;
setSectionLevel: (axis: 'x' | 'y' | 'z', v: number) => void;
setSectionPanelOpen: (open: boolean) => void;
setSectionPanelOpacity: (o: number) => void;
resetSection: () => void;
wireframeColor: string;
setWireframeColor: (color: string) => void;
@@ -541,6 +556,22 @@ export const useModelStore = create<ModelStore>((set, get) => ({
gridCalibMode: false,
setGridCalibMode: (gridCalibMode) => set({ gridCalibMode }),
sectionEnabled: { x: false, y: false, z: false },
sectionInvert: { x: false, y: false, z: false },
sectionLevel: { x: 0, y: 0, z: 0 },
sectionPanelOpen: false,
sectionPanelOpacity: 0.9,
setSectionEnabled: (axis, on) => set((s) => ({ sectionEnabled: { ...s.sectionEnabled, [axis]: on } })),
setSectionInvert: (axis, on) => set((s) => ({ sectionInvert: { ...s.sectionInvert, [axis]: on } })),
setSectionLevel: (axis, v) => set((s) => ({ sectionLevel: { ...s.sectionLevel, [axis]: v } })),
setSectionPanelOpen: (sectionPanelOpen) => set({ sectionPanelOpen }),
setSectionPanelOpacity: (sectionPanelOpacity) => set({ sectionPanelOpacity }),
resetSection: () => set({
sectionEnabled: { x: false, y: false, z: false },
sectionInvert: { x: false, y: false, z: false },
sectionLevel: { x: 0, y: 0, z: 0 },
}),
wireframeColor: '#00f3ff',
setWireframeColor: (wireframeColor) => set({ wireframeColor }),