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:
+37
-32
@@ -1,42 +1,47 @@
|
|||||||
|
|
||||||
## Objetivo
|
## 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`)
|
## Comportamento técnico
|
||||||
- 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`.
|
|
||||||
|
|
||||||
### 2. ViewCube já existente
|
- `gl.localClippingEnabled = true` no `onCreated` do `<Canvas>`.
|
||||||
- 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.
|
- Novos campos em `useModelStore` (não persistidos):
|
||||||
|
- `sectionEnabled: { x: boolean; y: boolean; z: boolean }`
|
||||||
### 3. Calibração refinada (`ViewCube.tsx` + `viewCubeBus.ts`)
|
- `sectionInvert: { x: boolean; y: boolean; z: boolean }`
|
||||||
- Ao iniciar a calibração, **forçar modo ortográfico** automaticamente (salvando o modo anterior para restaurar ao concluir/cancelar).
|
- `sectionLevel: { x: number; y: number; z: number }` (em metros, mundo)
|
||||||
- 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.
|
- `sectionPanelOpen: boolean`
|
||||||
- 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.
|
- `sectionPanelOpacity: number` (0.2–1)
|
||||||
- Mostrar no hint: "olhando para FRENTE — clique na face frontal da peça" (texto dinâmico do passo).
|
- Setters por eixo + `resetSection()` (zera enabled, invert e leva level ao centro do bbox).
|
||||||
- Manter o fluxo de 4 cliques + verificação opcional já aprovado.
|
- Helper `hasActiveSection()` derivado de `sectionEnabled`.
|
||||||
|
- Novo componente `<SectionClippingApplier>` montado dentro do `<Canvas>`:
|
||||||
### 4. Persistência
|
- 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).
|
||||||
- `cameraMode` salvo globalmente (preferência do app, não por peça).
|
- Aplica `material.clippingPlanes = ativos` em cada `Mesh` dos grupos registrados em `modelTransforms`. Restaura `[]` ao desligar todos os eixos.
|
||||||
- `calibrationQuat` por peça continua como já está.
|
- Limites dos sliders: bbox mundial agregado dos `getAllModelLocalGroups()`. Calculado quando o painel abre e quando o usuário aciona "Recalcular limites".
|
||||||
|
|
||||||
## Arquivos afetados
|
## Arquivos afetados
|
||||||
|
|
||||||
- `src/stores/useModelStore.ts` — campo `cameraMode` + setter.
|
- `src/stores/useModelStore.ts` — novos campos e setters de seção + painel.
|
||||||
- `src/components/three/ModelViewer.tsx` — alternância de câmera, sincronização de posição/zoom.
|
- `src/components/three/ModelViewer.tsx` — `localClippingEnabled` no GL; monta `<SectionClippingApplier>`.
|
||||||
- `src/components/ViewerControls.tsx` — botão de alternância Persp/Ortho.
|
- `src/components/SectionCutPanel.tsx` (novo) — janela pop-up flutuante com 3 sliders, switches, opacidade.
|
||||||
- `src/components/ViewCube.tsx` — força ortho durante calibração, anima vista após cada cube-click.
|
- `src/components/ViewerControls.tsx` — botão "Cortes" + lixeira condicional.
|
||||||
- `src/components/three/viewCubeBus.ts` — snap angular da normal capturada.
|
- `src/pages/Viewer.tsx` — monta `<SectionCutPanel />` na área do viewer.
|
||||||
|
|
||||||
## 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?
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { Slider } from '@/components/ui/slider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
@@ -37,7 +37,9 @@ export function ViewerControls() {
|
|||||||
hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection,
|
hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection,
|
||||||
cameraMode, setCameraMode,
|
cameraMode, setCameraMode,
|
||||||
gridCalibMode, setGridCalibMode, setGridAutoFollow,
|
gridCalibMode, setGridCalibMode, setGridAutoFollow,
|
||||||
|
sectionPanelOpen, setSectionPanelOpen, sectionEnabled, resetSection,
|
||||||
} = useModelStore();
|
} = useModelStore();
|
||||||
|
const sectionActive = sectionEnabled.x || sectionEnabled.y || sectionEnabled.z;
|
||||||
const activeModel = models.find(m => m.id === activeModelId);
|
const activeModel = models.find(m => m.id === activeModelId);
|
||||||
const hasSelection = selectedElementKeys.size > 0;
|
const hasSelection = selectedElementKeys.size > 0;
|
||||||
const hasHiddenOrIsolated = hiddenElementKeys.size > 0 || isolatedElementKeys !== null;
|
const hasHiddenOrIsolated = hiddenElementKeys.size > 0 || isolatedElementKeys !== null;
|
||||||
@@ -131,9 +133,38 @@ export function ViewerControls() {
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</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 */}
|
{/* Scale presets */}
|
||||||
<ScaleSelector />
|
<ScaleSelector />
|
||||||
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={renderMode === 'edges' ? 'default' : 'outline'}
|
variant={renderMode === 'edges' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1115,6 +1115,72 @@ function GridCalibrationHandler() {
|
|||||||
return null;
|
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. */
|
/** Mouse drag handler for desktop "Posicionar" mode: translates/rotates the active model. */
|
||||||
function PositionDragHandler() {
|
function PositionDragHandler() {
|
||||||
const { camera, gl, scene } = useThree();
|
const { camera, gl, scene } = useThree();
|
||||||
@@ -1283,6 +1349,7 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
|||||||
const isApple = /Mac|iPhone|iPad/.test(navigator.platform);
|
const isApple = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||||
const maxRatio = isApple ? 2 : 1.5;
|
const maxRatio = isApple ? 2 : 1.5;
|
||||||
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
|
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
|
||||||
|
gl.localClippingEnabled = true;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CameraSwitcher />
|
<CameraSwitcher />
|
||||||
@@ -1302,6 +1369,7 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
|||||||
<GridLayer />
|
<GridLayer />
|
||||||
<GridAutoFollower />
|
<GridAutoFollower />
|
||||||
<GridCalibrationHandler />
|
<GridCalibrationHandler />
|
||||||
|
<SectionClippingApplier />
|
||||||
|
|
||||||
<PositionDragHandler />
|
<PositionDragHandler />
|
||||||
<SelectionHandler />
|
<SelectionHandler />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FineTuningControls } from "@/components/FineTuningControls";
|
|||||||
import { MeasurementsList } from "@/components/MeasurementsList";
|
import { MeasurementsList } from "@/components/MeasurementsList";
|
||||||
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
|
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
|
||||||
import { ViewCube } from "@/components/ViewCube";
|
import { ViewCube } from "@/components/ViewCube";
|
||||||
|
import { SectionCutPanel } from "@/components/SectionCutPanel";
|
||||||
import { ShareButton } from "@/components/ShareButton";
|
import { ShareButton } from "@/components/ShareButton";
|
||||||
import { CloudLoader } from "@/components/CloudLoader";
|
import { CloudLoader } from "@/components/CloudLoader";
|
||||||
import { SceneModelList } from "@/components/SceneModelList";
|
import { SceneModelList } from "@/components/SceneModelList";
|
||||||
@@ -141,6 +142,7 @@ const Viewer = () => {
|
|||||||
<ModelViewerCanvas />
|
<ModelViewerCanvas />
|
||||||
<ViewCube />
|
<ViewCube />
|
||||||
<ViewerControls />
|
<ViewerControls />
|
||||||
|
<SectionCutPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Side panel */}
|
{/* Side panel */}
|
||||||
|
|||||||
@@ -261,6 +261,21 @@ interface ModelStore {
|
|||||||
gridCalibMode: boolean;
|
gridCalibMode: boolean;
|
||||||
setGridCalibMode: (b: boolean) => void;
|
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;
|
wireframeColor: string;
|
||||||
setWireframeColor: (color: string) => void;
|
setWireframeColor: (color: string) => void;
|
||||||
|
|
||||||
@@ -541,6 +556,22 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
gridCalibMode: false,
|
gridCalibMode: false,
|
||||||
setGridCalibMode: (gridCalibMode) => set({ gridCalibMode }),
|
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',
|
wireframeColor: '#00f3ff',
|
||||||
setWireframeColor: (wireframeColor) => set({ wireframeColor }),
|
setWireframeColor: (wireframeColor) => set({ wireframeColor }),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user