Adicionou seleção de objetos
X-Lovable-Edit-ID: edt-071f10f1-d714-4ff4-a035-238ee8bd2b72 Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
+73
-40
@@ -1,50 +1,83 @@
|
||||
# Correções no modo Medir
|
||||
|
||||
## Problemas
|
||||
1. Ao ativar **Medir**, o OrbitControls é desabilitado (`enabled={!positionMode && !measureMode}`), bloqueando rotação/zoom/pan naturais com o mouse.
|
||||
2. O label da medida usa `useLabelDistanceFactor` que recalcula `distanceFactor` por frame para manter tamanho **constante** na tela. Resultado: ao aproximar a peça o texto não cresce e fica ilegível.
|
||||
# Seleção de elementos IFC — Esconder, Isolar e Exportar GLB
|
||||
|
||||
## Mudanças em `src/components/three/ModelViewer.tsx`
|
||||
Adicionar um modo de **seleção por elemento** (viga, chapa, etc.) que permite ao usuário escolher partes do modelo e então **esconder**, **isolar** ou **exportar como GLB reduzido** para reuso. Funciona no desktop e no AR/XR (Quest 3) em uma única fase.
|
||||
|
||||
### 1. Reabilitar controles de câmera no modo Medir
|
||||
No `<OrbitControls>` (linha ~792), trocar:
|
||||
```tsx
|
||||
enabled={!positionMode && !measureMode}
|
||||
## Fluxo do usuário
|
||||
|
||||
1. Usuário ativa botão **"Selecionar"** no ViewerControls (e no XRHud).
|
||||
2. Clica em elementos do modelo — cada clique alterna seleção (highlight laranja/emissive).
|
||||
3. Contador mostra "N elementos selecionados".
|
||||
4. Três ações disponíveis:
|
||||
- **Esconder selecionados** → some com eles
|
||||
- **Isolar selecionados** → esconde todo o resto
|
||||
- **Mostrar tudo** → restaura
|
||||
5. Botão extra **"Exportar GLB"** salva arquivo `.glb` contendo apenas os elementos selecionados, baixável pelo navegador. Esse GLB pode ser recarregado no app pelo fluxo normal de import.
|
||||
6. Estado de visibilidade é **persistido por `fileName`** no `localStorage`, igual ao `PLACEMENT_KEY`, sobrevivendo a recarregar a página e a sair/entrar do AR.
|
||||
|
||||
## Granularidade
|
||||
|
||||
Seleção opera no **elemento IFC inteiro** (uma viga, uma chapa). O converter de IFC preserva a hierarquia: cada `IfcProduct` vira um `Group` ou `Mesh` nomeado no GLB. O picking sobe na árvore até encontrar o ancestral marcado como "elemento IFC raiz" (campo `userData.ifcElement = true` adicionado durante a conversão), garantindo que clicar em qualquer face seleciona a viga toda.
|
||||
|
||||
## Detalhes técnicos
|
||||
|
||||
### 1. `src/lib/convertIFC.ts`
|
||||
- Durante a conversão IFC→GLB, marcar cada `IfcProduct` resultante com `obj.userData.ifcElement = true` e `userData.ifcId = expressId`. Sem isso, a árvore three.js fica plana e não dá pra distinguir "elemento" de "submesh".
|
||||
|
||||
### 2. `src/stores/useModelStore.ts`
|
||||
Novo slice de seleção:
|
||||
```ts
|
||||
selectionMode: boolean
|
||||
selectedElementIds: Set<string> // chave: `${modelId}:${ifcId|uuid}`
|
||||
hiddenElementIds: Set<string>
|
||||
isolatedElementIds: Set<string> | null // null = sem isolamento
|
||||
toggleSelection(key), clearSelection(),
|
||||
hideSelected(), isolateSelected(), showAll()
|
||||
```
|
||||
por:
|
||||
```tsx
|
||||
enabled={!positionMode}
|
||||
Persistência de `hiddenElementIds`/`isolatedElementIds` por `fileName` em `localStorage` (chave nova `tsxr_visibility_v1`), aplicada ao carregar o modelo.
|
||||
|
||||
### 3. `src/components/three/ModelViewer.tsx`
|
||||
- Novo componente `SelectionHandler`: ativo quando `selectionMode === true`. Raycaster no `onPointerDown`, sobe a árvore via `object.parent` até achar `userData.ifcElement === true`, faz toggle no store. Desabilita OrbitControls durante o clique para não conflitar.
|
||||
- Aplicar visibilidade: `useEffect` percorre `scene.traverse`, define `obj.visible` conforme `hiddenElementIds`/`isolatedElementIds`.
|
||||
- Highlight de seleção: material `emissive` laranja (`#f59e0b`, mesma cor de holes) com `emissiveIntensity = 0.4`, restaurado ao deselecionar. Guardar material original em `userData.originalMaterial`.
|
||||
- `selectionMode` é mutuamente exclusivo com `measureMode` e `positionMode` (mesma lógica que esses dois já têm entre si).
|
||||
|
||||
### 4. `src/components/three/XRControllerMeasure.tsx` (ou novo `XRControllerSelect.tsx`)
|
||||
- Quando `selectionMode === true` em XR: usar o mesmo ray do controller, no `selectstart` (trigger) faz toggle de seleção em vez de pontos de medição.
|
||||
- Tip do laser muda para laranja durante o modo seleção.
|
||||
|
||||
### 5. `src/components/ViewerControls.tsx` + `src/components/XRHud.tsx`
|
||||
Novos botões:
|
||||
- `Selecionar` (toggle, ícone `MousePointerClick`)
|
||||
- `Esconder` / `Isolar` / `Mostrar tudo` (visíveis quando `selectionMode` ou há ocultos)
|
||||
- `Exportar GLB` (visível quando há seleção)
|
||||
- Badge com contador "N selecionados"
|
||||
|
||||
### 6. `src/lib/exportSelectionGLB.ts` (novo)
|
||||
```ts
|
||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js';
|
||||
// Clona apenas os elementos selecionados em uma nova Scene,
|
||||
// preserva transformações mundiais, exporta como .glb binário,
|
||||
// dispara download via Blob + <a download>.
|
||||
```
|
||||
O `MeasureClickHandler` já tem **drag gate de 4 px** (`pointerdown` + `pointerup` só conta como clique se o mouse moveu menos que 4 px e foi botão esquerdo). Isso significa:
|
||||
- Botão esquerdo arrastando → OrbitControls rotaciona, nenhum ponto é adicionado.
|
||||
- Botão direito → pan do OrbitControls (handler ignora `e.button !== 0`).
|
||||
- Roda → zoom do OrbitControls (handler ignora).
|
||||
- Clique curto esquerdo → adiciona ponto de medida (com snap).
|
||||
Nome do arquivo: `{original}_selecao_{N}elem.glb`. O GLB resultante pode ser aberto pelo próprio app via fluxo normal de import (já aceita `.glb`).
|
||||
|
||||
### 2. Label da medida escala com o zoom e fica ~4x maior
|
||||
Substituir `MeasurementLabel` para usar `distanceFactor` **fixo** (em vez de calculado por frame). Com `<Html distanceFactor={N}>`, o tamanho na tela é proporcional a `N / distance` — ou seja, **cresce naturalmente ao aproximar a câmera**.
|
||||
### 7. Performance
|
||||
- Aplicar visibilidade via `obj.visible = false` (não remove geometria — reversível e barato).
|
||||
- Frustum culling do three.js já ignora invisíveis no render → ganho real de FPS no Quest mesmo sem exportar.
|
||||
|
||||
- Remover o uso de `useLabelDistanceFactor` no label de medida (manter, se desejado, apenas no `hoverInfo`).
|
||||
- Definir `distanceFactor` fixo calibrado para 1:1 mm (valor inicial ~`8`, ajustável).
|
||||
- Aumentar o tamanho base do texto de `text-[11px]` para `text-[16px]` e `px-2 py-0.5` → `px-3 py-1` para acompanhar a proporção (~4x área visual final somando o aumento do distanceFactor + texto).
|
||||
## Arquivos tocados
|
||||
|
||||
```tsx
|
||||
function MeasurementLabel({ position, text, variant }: { ... }) {
|
||||
const borderClass = variant === 'success' ? 'border-success/60' : 'border-primary/60';
|
||||
const textClass = variant === 'success' ? 'text-success' : 'text-primary';
|
||||
return (
|
||||
<Html position={position} center distanceFactor={8} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}>
|
||||
<div className={`rounded bg-card/95 border ${borderClass} px-3 py-1 shadow-lg backdrop-blur-sm`}>
|
||||
<span className={`font-mono text-[16px] font-bold ${textClass} whitespace-nowrap`}>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
```
|
||||
- `src/lib/convertIFC.ts` — marcar `userData.ifcElement`
|
||||
- `src/lib/exportSelectionGLB.ts` — **novo**
|
||||
- `src/stores/useModelStore.ts` — slice de seleção + persistência
|
||||
- `src/components/three/ModelViewer.tsx` — `SelectionHandler`, highlight, aplicar visibilidade
|
||||
- `src/components/three/XRControllerMeasure.tsx` — handler de seleção em XR
|
||||
- `src/components/ViewerControls.tsx` — botões desktop
|
||||
- `src/components/XRHud.tsx` — botões XR
|
||||
|
||||
O `hoverInfo` (auto-detect) pode manter o comportamento atual de tamanho constante para não poluir a cena ao passar o mouse.
|
||||
## Fora do escopo (decidido)
|
||||
|
||||
## Sem mudanças em outros arquivos
|
||||
Store, AR, snap e PointMarker permanecem intocados.
|
||||
- **Não** exportar IFC reduzido (decidido: arquivo é só pra este app, GLB resolve e é trivial).
|
||||
- **Não** suportar seleção por face/sub-mesh (decidido: elemento inteiro).
|
||||
- **Não** salvar conjuntos nomeados de seleção ("layout A", "layout B") — pode virar Fase 2 se você pedir.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2 } from 'lucide-react';
|
||||
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download } from 'lucide-react';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
@@ -6,6 +6,9 @@ import { useModelStore } from '@/stores/useModelStore';
|
||||
import { ScaleSelector } from '@/components/ScaleSelector';
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { exportObjectsAsGLB } from '@/lib/exportSelectionGLB';
|
||||
import * as THREE from 'three';
|
||||
import { elementKey, sceneRef } from '@/components/three/ModelViewer';
|
||||
|
||||
const WIRE_COLORS = [
|
||||
{ label: 'Cinza', value: '#8899aa' },
|
||||
@@ -28,8 +31,36 @@ export function ViewerControls() {
|
||||
edgeThresholdAngle, setEdgeThresholdAngle,
|
||||
positionMode, setPositionMode,
|
||||
activeModelId, models,
|
||||
selectionMode, setSelectionMode,
|
||||
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
|
||||
hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection,
|
||||
} = useModelStore();
|
||||
const activeModel = models.find(m => m.id === activeModelId);
|
||||
const hasSelection = selectedElementKeys.size > 0;
|
||||
const hasHiddenOrIsolated = hiddenElementKeys.size > 0 || isolatedElementKeys !== null;
|
||||
|
||||
const handleExportSelection = useCallback(async () => {
|
||||
if (!activeModel || selectedElementKeys.size === 0) return;
|
||||
const scene = sceneRef.current;
|
||||
if (!scene) { toast.error('Cena 3D não disponível'); return; }
|
||||
const objects: THREE.Object3D[] = [];
|
||||
const collect = (root: THREE.Object3D) => {
|
||||
const modelId = root.userData?.modelId as string | undefined;
|
||||
if (!modelId) { root.children.forEach(collect); return; }
|
||||
root.traverse((el) => {
|
||||
if (!el.userData?.ifcElement) return;
|
||||
if (selectedElementKeys.has(elementKey(modelId, el))) objects.push(el);
|
||||
});
|
||||
};
|
||||
collect(scene);
|
||||
if (objects.length === 0) {
|
||||
toast.error('Não foi possível localizar elementos selecionados. Use IFC para seleção persistente.');
|
||||
return;
|
||||
}
|
||||
const base = activeModel.fileName.replace(/\.(glb|gltf|ifc|obj|stl)$/i, '');
|
||||
await exportObjectsAsGLB(objects, `${base}_selecao_${objects.length}elem.glb`);
|
||||
toast.success(`GLB exportado com ${objects.length} elemento(s)`);
|
||||
}, [activeModel, selectedElementKeys]);
|
||||
|
||||
const handleScreenshot = useCallback(() => {
|
||||
const canvas = document.querySelector('canvas');
|
||||
@@ -207,6 +238,49 @@ export function ViewerControls() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection cluster */}
|
||||
<div className="flex items-end gap-1.5">
|
||||
<Button
|
||||
variant={selectionMode ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="gap-2 h-9"
|
||||
onClick={() => setSelectionMode(!selectionMode)}
|
||||
disabled={!activeModelId}
|
||||
title="Clique nos elementos do modelo para selecionar (viga, chapa…)"
|
||||
>
|
||||
<MousePointerClick className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-xs">
|
||||
{selectionMode
|
||||
? hasSelection ? `${selectedElementKeys.size} sel.` : 'Selecionar…'
|
||||
: 'Selecionar'}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{hasSelection && (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9" onClick={hideSelectedElements} title="Esconder selecionados">
|
||||
<EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9" onClick={isolateSelectedElements} title="Isolar selecionados (esconder o resto)">
|
||||
<Focus className="h-3.5 w-3.5 text-primary" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9" onClick={handleExportSelection} title="Exportar selecionados como GLB">
|
||||
<Download className="h-3.5 w-3.5 text-primary" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9" onClick={clearElementSelection} title="Limpar seleção">
|
||||
<Undo2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasHiddenOrIsolated && (
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-1.5" onClick={showAllElements} title="Mostrar todos os elementos">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-xs">Mostrar tudo</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Screenshot */}
|
||||
<Button
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera,
|
||||
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip,
|
||||
ClipboardCheck, X, Crosshair, Magnet, HelpCircle, Maximize2,
|
||||
MousePointerClick, EyeOff, Focus,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
@@ -116,7 +117,12 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
|
||||
fineTuning, setFineTuning, resetFineTuning,
|
||||
addScreenshot,
|
||||
checklist,
|
||||
selectionMode, setSelectionMode,
|
||||
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
|
||||
hideSelectedElements, isolateSelectedElements, showAllElements,
|
||||
} = useModelStore();
|
||||
const hasSelection = selectedElementKeys.size > 0;
|
||||
const hasHidden = hiddenElementKeys.size > 0 || isolatedElementKeys !== null;
|
||||
|
||||
const approved = checklist.filter(i => i.status === 'approved').length;
|
||||
const rejected = checklist.filter(i => i.status === 'rejected').length;
|
||||
@@ -373,6 +379,34 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Selection */}
|
||||
<Button
|
||||
variant={selectionMode ? 'default' : 'outline'}
|
||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||
onClick={() => setSelectionMode(!selectionMode)}
|
||||
title="Selecionar elementos (viga, chapa…) — gatilho direito alterna"
|
||||
>
|
||||
<MousePointerClick className="h-3.5 w-3.5" />
|
||||
{selectionMode ? (hasSelection ? `${selectedElementKeys.size} sel.` : 'Selec…') : 'Selec'}
|
||||
</Button>
|
||||
|
||||
{hasSelection && (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={hideSelectedElements} title="Esconder">
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={isolateSelectedElements} title="Isolar">
|
||||
<Focus className="h-3.5 w-3.5 text-primary" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{hasHidden && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={showAllElements} title="Mostrar tudo">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
<Button variant="outline" size="icon" className="h-8 w-8" onClick={handleScreenshot} title="Screenshot">
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -10,6 +10,36 @@ interface ModelViewerProps {
|
||||
url?: string; // legacy, ignored — uses store.models
|
||||
}
|
||||
|
||||
/** Walk up the parent chain until we find a node tagged as `ifcElement`,
|
||||
* or the model root. Returns null if neither found. */
|
||||
export function findElementRoot(obj: THREE.Object3D | null): THREE.Object3D | null {
|
||||
let cur: THREE.Object3D | null = obj;
|
||||
while (cur) {
|
||||
if (cur.userData?.ifcElement) return cur;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Stable key for an element across reloads: prefers ifcId, falls back to name. */
|
||||
export function elementKey(modelId: string, el: THREE.Object3D): string {
|
||||
const id = el.userData?.ifcId ?? el.name ?? el.uuid;
|
||||
return `${modelId}:${id}`;
|
||||
}
|
||||
|
||||
/** Globally accessible ref to the active R3F scene (set by SceneRefCapture). */
|
||||
export const sceneRef: { current: THREE.Scene | null } = { current: null };
|
||||
|
||||
function SceneRefCapture() {
|
||||
const { scene } = useThree();
|
||||
useEffect(() => {
|
||||
sceneRef.current = scene;
|
||||
return () => { if (sceneRef.current === scene) sceneRef.current = null; };
|
||||
}, [scene]);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
<Html center>
|
||||
@@ -644,6 +674,130 @@ function MeasureClickHandler() {
|
||||
}
|
||||
|
||||
|
||||
/** Pointer handler for element-selection mode. Clicks an IFC element, sub-mesh
|
||||
* of a non-IFC model, or any pickable mesh group. Toggles selection in store. */
|
||||
function SelectionHandler() {
|
||||
const { camera, scene, gl } = useThree();
|
||||
const selectionMode = useModelStore((s) => s.selectionMode);
|
||||
const raycaster = useMemo(() => new THREE.Raycaster(), []);
|
||||
const mouse = useMemo(() => new THREE.Vector2(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionMode) return;
|
||||
const prevCursor = gl.domElement.style.cursor;
|
||||
gl.domElement.style.cursor = 'cell';
|
||||
const canvas = gl.domElement;
|
||||
let downX = 0, downY = 0, armed = false;
|
||||
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (e.button !== 0) { armed = false; return; }
|
||||
downX = e.clientX; downY = e.clientY; armed = true;
|
||||
};
|
||||
const onUp = (e: PointerEvent) => {
|
||||
if (!armed) return;
|
||||
armed = false;
|
||||
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 4) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(scene.children, true);
|
||||
const hit = intersects.find((i) => {
|
||||
const o = i.object;
|
||||
if (!(o instanceof THREE.Mesh)) return false;
|
||||
if (o.userData.__edgeLine) return false;
|
||||
if (o.geometry instanceof THREE.SphereGeometry) return false;
|
||||
if (o.geometry instanceof THREE.RingGeometry) return false;
|
||||
if (o.geometry instanceof THREE.PlaneGeometry) return false;
|
||||
return true;
|
||||
});
|
||||
if (!hit) return;
|
||||
|
||||
// Walk up to find ifcElement node AND the GLBModel root (carrying modelId)
|
||||
let cur: THREE.Object3D | null = hit.object;
|
||||
let element: THREE.Object3D | null = null;
|
||||
let modelId: string | null = null;
|
||||
while (cur) {
|
||||
if (!element && cur.userData?.ifcElement) element = cur;
|
||||
if (!modelId && cur.userData?.modelId) modelId = cur.userData.modelId as string;
|
||||
cur = cur.parent;
|
||||
}
|
||||
// Non-IFC fallback: pick the mesh itself
|
||||
if (!element) element = hit.object;
|
||||
if (!modelId) modelId = useModelStore.getState().activeModelId;
|
||||
if (!modelId || !element) return;
|
||||
const key = elementKey(modelId, element);
|
||||
useModelStore.getState().toggleElementSelection(key);
|
||||
};
|
||||
|
||||
canvas.addEventListener('pointerdown', onDown);
|
||||
canvas.addEventListener('pointerup', onUp);
|
||||
return () => {
|
||||
canvas.removeEventListener('pointerdown', onDown);
|
||||
canvas.removeEventListener('pointerup', onUp);
|
||||
gl.domElement.style.cursor = prevCursor;
|
||||
};
|
||||
}, [selectionMode, camera, scene, gl, raycaster, mouse]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Walks the scene each time visibility/selection state changes and applies
|
||||
* per-element visibility + emissive highlight for selected elements. */
|
||||
function VisibilityApplier() {
|
||||
const { scene } = useThree();
|
||||
const nonce = useModelStore((s) => s.visibilityNonce);
|
||||
const models = useModelStore((s) => s.models);
|
||||
const opacity = useModelStore((s) => s.opacity);
|
||||
const renderMode = useModelStore((s) => s.renderMode);
|
||||
const checklist = useModelStore((s) => s.checklist);
|
||||
|
||||
useEffect(() => {
|
||||
const st = useModelStore.getState();
|
||||
const { hiddenElementKeys, isolatedElementKeys, selectedElementKeys } = st;
|
||||
|
||||
scene.traverse((root) => {
|
||||
const modelId = root.userData?.modelId as string | undefined;
|
||||
if (!modelId) return;
|
||||
// For each ifcElement descendant inside this model root
|
||||
root.traverse((el) => {
|
||||
if (!el.userData?.ifcElement) return;
|
||||
const key = elementKey(modelId, el);
|
||||
const hidden = hiddenElementKeys.has(key);
|
||||
const isolated = isolatedElementKeys ? !isolatedElementKeys.has(key) : false;
|
||||
el.visible = !(hidden || isolated);
|
||||
|
||||
const selected = selectedElementKeys.has(key);
|
||||
el.traverse((m) => {
|
||||
if (!(m instanceof THREE.Mesh)) return;
|
||||
const mats = Array.isArray(m.material) ? m.material : [m.material];
|
||||
mats.forEach((mat) => {
|
||||
if (!(mat instanceof THREE.MeshStandardMaterial)) return;
|
||||
if (selected) {
|
||||
if (m.userData.__origEmissive === undefined) {
|
||||
m.userData.__origEmissive = mat.emissive.getHex();
|
||||
m.userData.__origEmissiveIntensity = mat.emissiveIntensity;
|
||||
}
|
||||
mat.emissive.set('#f59e0b');
|
||||
mat.emissiveIntensity = 0.6;
|
||||
} else if (m.userData.__origEmissive !== undefined) {
|
||||
mat.emissive.setHex(m.userData.__origEmissive as number);
|
||||
mat.emissiveIntensity = (m.userData.__origEmissiveIntensity as number) ?? 0;
|
||||
delete m.userData.__origEmissive;
|
||||
delete m.userData.__origEmissiveIntensity;
|
||||
}
|
||||
mat.needsUpdate = true;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [scene, nonce, models, opacity, renderMode, checklist]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
function GridLayer() {
|
||||
const showGrid = useModelStore((s) => s.showGrid);
|
||||
const gridY = useModelStore((s) => s.gridY);
|
||||
@@ -851,6 +1005,7 @@ function PositionDragHandler() {
|
||||
export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
||||
const positionMode = useModelStore((s) => s.positionMode);
|
||||
const measureMode = useModelStore((s) => s.measureMode);
|
||||
const selectionMode = useModelStore((s) => s.selectionMode);
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
|
||||
@@ -880,6 +1035,9 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
||||
<GridAutoFollower />
|
||||
|
||||
<PositionDragHandler />
|
||||
<SelectionHandler />
|
||||
<VisibilityApplier />
|
||||
<SceneRefCapture />
|
||||
|
||||
<OrbitControls
|
||||
makeDefault
|
||||
@@ -887,8 +1045,9 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
||||
dampingFactor={0.1}
|
||||
minDistance={0.05}
|
||||
maxDistance={50}
|
||||
enabled={!positionMode}
|
||||
enabled={!positionMode && !selectionMode}
|
||||
/>
|
||||
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,9 +51,10 @@ export function XRControllerMeasure() {
|
||||
|
||||
useFrame((_state, _dt, frame: XRFrame | undefined) => {
|
||||
const measureMode = useModelStore.getState().measureMode;
|
||||
const selectionMode = useModelStore.getState().selectionMode;
|
||||
if (laserRef.current) laserRef.current.visible = false;
|
||||
if (tipRef.current) tipRef.current.visible = false;
|
||||
if (!measureMode || !frame) return;
|
||||
if ((!measureMode && !selectionMode) || !frame) return;
|
||||
|
||||
const session = frame.session;
|
||||
const refSpace = gl.xr.getReferenceSpace();
|
||||
@@ -199,7 +200,7 @@ export function XRControllerMeasure() {
|
||||
positions.needsUpdate = true;
|
||||
laserGeom.current.computeBoundingSphere();
|
||||
|
||||
const color = snapKind === 'hole' ? '#f59e0b' : snapKind === 'vertex' ? '#22c55e' : snapKind === 'edge' ? '#3b82f6' : '#eab308';
|
||||
const color = selectionMode ? '#a855f7' : (snapKind === 'hole' ? '#f59e0b' : snapKind === 'vertex' ? '#22c55e' : snapKind === 'edge' ? '#3b82f6' : '#eab308');
|
||||
tipColor.current.set(color);
|
||||
(laserRef.current.material as THREE.LineBasicMaterial).color.set(color);
|
||||
((tipRef.current.material as THREE.MeshBasicMaterial)).color.copy(tipColor.current);
|
||||
@@ -215,12 +216,29 @@ export function XRControllerMeasure() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Right trigger: add point ──────────────────────────────────────
|
||||
// ── Right trigger: add point (measure) OR toggle selection ────────
|
||||
const trigVal = gp?.buttons?.[0]?.value ?? (gp?.buttons?.[0]?.pressed ? 1 : 0);
|
||||
if (!trigState.current && trigVal > TRIG_ON) {
|
||||
trigState.current = true;
|
||||
if (snappedPoint) {
|
||||
useModelStore.getState().addMeasurePoint({
|
||||
const st = useModelStore.getState();
|
||||
if (st.selectionMode && hit && hit.object instanceof THREE.Mesh) {
|
||||
// Walk up to find ifcElement + modelId
|
||||
let cur: THREE.Object3D | null = hit.object;
|
||||
let element: THREE.Object3D | null = null;
|
||||
let modelId: string | null = null;
|
||||
while (cur) {
|
||||
if (!element && cur.userData?.ifcElement) element = cur;
|
||||
if (!modelId && cur.userData?.modelId) modelId = cur.userData.modelId as string;
|
||||
cur = cur.parent;
|
||||
}
|
||||
if (!element) element = hit.object;
|
||||
if (!modelId) modelId = st.activeModelId;
|
||||
if (modelId && element) {
|
||||
const id = element.userData?.ifcId ?? element.name ?? element.uuid;
|
||||
st.toggleElementSelection(`${modelId}:${id}`);
|
||||
}
|
||||
} else if (snappedPoint) {
|
||||
st.addMeasurePoint({
|
||||
x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z,
|
||||
});
|
||||
}
|
||||
|
||||
+11
-7
@@ -23,6 +23,13 @@ export async function convertIFCtoGLB(
|
||||
|
||||
ifcApi.StreamAllMeshes(modelID, (mesh: WebIFC.FlatMesh) => {
|
||||
const placedGeometries = mesh.geometries;
|
||||
const expressID = (mesh as unknown as { expressID?: number }).expressID ?? 0;
|
||||
|
||||
// One Group per IfcProduct → represents a single "element" (beam, plate…).
|
||||
// userData is preserved by GLTFExporter as `extras` and survives reload.
|
||||
const elementGroup = new THREE.Group();
|
||||
elementGroup.name = `ifc_${expressID}`;
|
||||
elementGroup.userData = { ifcElement: true, ifcId: expressID };
|
||||
|
||||
for (let i = 0; i < placedGeometries.size(); i++) {
|
||||
const placedGeometry = placedGeometries.get(i);
|
||||
@@ -38,8 +45,6 @@ export async function convertIFCtoGLB(
|
||||
);
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
|
||||
// web-ifc vertex format: x, y, z, nx, ny, nz (6 floats per vertex)
|
||||
const positionArray = new Float32Array(verts.length / 2);
|
||||
const normalArray = new Float32Array(verts.length / 2);
|
||||
|
||||
@@ -57,10 +62,8 @@ export async function convertIFCtoGLB(
|
||||
geometry.setAttribute('normal', new THREE.BufferAttribute(normalArray, 3));
|
||||
geometry.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
|
||||
// Get or create material based on color
|
||||
const color = placedGeometry.color;
|
||||
const colorKey = (color.x * 255) << 16 | (color.y * 255) << 8 | (color.z * 255);
|
||||
|
||||
let material = materials.get(colorKey);
|
||||
if (!material) {
|
||||
material = new THREE.MeshStandardMaterial({
|
||||
@@ -75,16 +78,17 @@ export async function convertIFCtoGLB(
|
||||
}
|
||||
|
||||
const mesh3 = new THREE.Mesh(geometry, material);
|
||||
mesh3.userData = { ifcId: expressID };
|
||||
|
||||
// Apply placement transform
|
||||
const matrix = new THREE.Matrix4();
|
||||
matrix.fromArray(placedGeometry.flatTransformation);
|
||||
mesh3.applyMatrix4(matrix);
|
||||
|
||||
scene.add(mesh3);
|
||||
|
||||
elementGroup.add(mesh3);
|
||||
ifcGeometry.delete();
|
||||
}
|
||||
|
||||
if (elementGroup.children.length > 0) scene.add(elementGroup);
|
||||
});
|
||||
|
||||
ifcApi.CloseModel(modelID);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js';
|
||||
|
||||
/**
|
||||
* Export the given root objects (already cloned or live in scene) as a single
|
||||
* binary GLB, preserving each one's current world transform. Triggers a
|
||||
* browser download.
|
||||
*/
|
||||
export async function exportObjectsAsGLB(
|
||||
objects: THREE.Object3D[],
|
||||
fileName: string
|
||||
): Promise<void> {
|
||||
if (objects.length === 0) return;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
for (const src of objects) {
|
||||
src.updateWorldMatrix(true, false);
|
||||
const clone = src.clone(true);
|
||||
// Bake world matrix into the clone so the export is position-correct
|
||||
// regardless of parent chain.
|
||||
clone.matrix.copy(src.matrixWorld);
|
||||
clone.matrix.decompose(clone.position, clone.quaternion, clone.scale);
|
||||
clone.matrixAutoUpdate = true;
|
||||
scene.add(clone);
|
||||
}
|
||||
|
||||
const exporter = new GLTFExporter();
|
||||
const glb = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
exporter.parse(
|
||||
scene,
|
||||
(res) => resolve(res as ArrayBuffer),
|
||||
(err) => reject(err),
|
||||
{ binary: true }
|
||||
);
|
||||
});
|
||||
|
||||
const blob = new Blob([glb], { type: 'model/gltf-binary' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName.endsWith('.glb') ? fileName : `${fileName}.glb`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||
}
|
||||
@@ -47,6 +47,30 @@ function savePlacement(fileName: string, fineTuning: FineTuning) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Visibility (selection-based hide/isolate) persistence ────────────
|
||||
const VISIBILITY_KEY = 'tsxr_visibility_v1';
|
||||
interface StoredVisibility { hidden: string[]; isolated: string[] | null; }
|
||||
function loadVisibilityMap(): Record<string, StoredVisibility> {
|
||||
try {
|
||||
const raw = localStorage.getItem(VISIBILITY_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
function saveVisibility(fileName: string, hidden: Set<string>, isolated: Set<string> | null) {
|
||||
try {
|
||||
const all = loadVisibilityMap();
|
||||
all[fileName] = { hidden: [...hidden], isolated: isolated ? [...isolated] : null };
|
||||
localStorage.setItem(VISIBILITY_KEY, JSON.stringify(all));
|
||||
} catch {}
|
||||
}
|
||||
function loadVisibility(fileName: string): { hidden: Set<string>; isolated: Set<string> | null } {
|
||||
const v = loadVisibilityMap()[fileName];
|
||||
if (!v) return { hidden: new Set(), isolated: null };
|
||||
return { hidden: new Set(v.hidden ?? []), isolated: v.isolated ? new Set(v.isolated) : null };
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface ScaleRatio {
|
||||
label: string; // e.g. "1:50", "2:1", "1:1"
|
||||
@@ -220,6 +244,23 @@ interface ModelStore {
|
||||
setMeasureMode: (on: boolean) => void;
|
||||
positionMode: boolean;
|
||||
setPositionMode: (on: boolean) => void;
|
||||
|
||||
// ── Selection (hide/isolate/export elements) ────────────
|
||||
selectionMode: boolean;
|
||||
setSelectionMode: (on: boolean) => void;
|
||||
/** Keys: `${modelId}:${ifcId|name|uuid}` */
|
||||
selectedElementKeys: Set<string>;
|
||||
/** Hidden element keys, per-active-model file (persisted by fileName). */
|
||||
hiddenElementKeys: Set<string>;
|
||||
/** When non-null, only these keys are visible (isolation). */
|
||||
isolatedElementKeys: Set<string> | null;
|
||||
toggleElementSelection: (key: string) => void;
|
||||
clearElementSelection: () => void;
|
||||
hideSelectedElements: () => void;
|
||||
isolateSelectedElements: () => void;
|
||||
showAllElements: () => void;
|
||||
/** Bump whenever visibility/selection state changes — viewer re-applies. */
|
||||
visibilityNonce: number;
|
||||
measurePoints: MeasurePoint[];
|
||||
addMeasurePoint: (p: MeasurePoint) => void;
|
||||
undoLastMeasurePoint: () => void;
|
||||
@@ -307,7 +348,16 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
||||
|
||||
setActiveModel: (id) => {
|
||||
const state = get();
|
||||
set({ activeModelId: id, ...syncActive({ models: state.models, activeModelId: id }) });
|
||||
const active = state.models.find(m => m.id === id);
|
||||
const vis = active ? loadVisibility(active.fileName) : { hidden: new Set<string>(), isolated: null };
|
||||
set({
|
||||
activeModelId: id,
|
||||
...syncActive({ models: state.models, activeModelId: id }),
|
||||
hiddenElementKeys: vis.hidden,
|
||||
isolatedElementKeys: vis.isolated,
|
||||
selectedElementKeys: new Set(),
|
||||
visibilityNonce: state.visibilityNonce + 1,
|
||||
});
|
||||
},
|
||||
|
||||
toggleModelVisible: (id) => set((state) => ({
|
||||
@@ -455,9 +505,48 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
||||
resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }),
|
||||
|
||||
measureMode: false,
|
||||
setMeasureMode: (measureMode) => set((s) => ({ measureMode, measurePoints: [], positionMode: measureMode ? false : s.positionMode })),
|
||||
setMeasureMode: (measureMode) => set((s) => ({ measureMode, measurePoints: [], positionMode: measureMode ? false : s.positionMode, selectionMode: measureMode ? false : s.selectionMode })),
|
||||
positionMode: false,
|
||||
setPositionMode: (positionMode) => set((s) => ({ positionMode, measureMode: positionMode ? false : s.measureMode })),
|
||||
setPositionMode: (positionMode) => set((s) => ({ positionMode, measureMode: positionMode ? false : s.measureMode, selectionMode: positionMode ? false : s.selectionMode })),
|
||||
|
||||
// ── Selection state ─────────────────────────────────────
|
||||
selectionMode: false,
|
||||
setSelectionMode: (on) => set((s) => ({
|
||||
selectionMode: on,
|
||||
measureMode: on ? false : s.measureMode,
|
||||
positionMode: on ? false : s.positionMode,
|
||||
selectedElementKeys: on ? s.selectedElementKeys : new Set(),
|
||||
})),
|
||||
selectedElementKeys: new Set<string>(),
|
||||
hiddenElementKeys: new Set<string>(),
|
||||
isolatedElementKeys: null,
|
||||
visibilityNonce: 0,
|
||||
toggleElementSelection: (key) => set((s) => {
|
||||
const next = new Set(s.selectedElementKeys);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return { selectedElementKeys: next, visibilityNonce: s.visibilityNonce + 1 };
|
||||
}),
|
||||
clearElementSelection: () => set((s) => ({ selectedElementKeys: new Set(), visibilityNonce: s.visibilityNonce + 1 })),
|
||||
hideSelectedElements: () => set((s) => {
|
||||
if (s.selectedElementKeys.size === 0) return {} as Partial<ModelStore>;
|
||||
const hidden = new Set(s.hiddenElementKeys);
|
||||
s.selectedElementKeys.forEach(k => hidden.add(k));
|
||||
const active = s.models.find(m => m.id === s.activeModelId);
|
||||
if (active) saveVisibility(active.fileName, hidden, s.isolatedElementKeys);
|
||||
return { hiddenElementKeys: hidden, selectedElementKeys: new Set(), visibilityNonce: s.visibilityNonce + 1 };
|
||||
}),
|
||||
isolateSelectedElements: () => set((s) => {
|
||||
if (s.selectedElementKeys.size === 0) return {} as Partial<ModelStore>;
|
||||
const iso = new Set(s.selectedElementKeys);
|
||||
const active = s.models.find(m => m.id === s.activeModelId);
|
||||
if (active) saveVisibility(active.fileName, s.hiddenElementKeys, iso);
|
||||
return { isolatedElementKeys: iso, selectedElementKeys: new Set(), visibilityNonce: s.visibilityNonce + 1 };
|
||||
}),
|
||||
showAllElements: () => set((s) => {
|
||||
const active = s.models.find(m => m.id === s.activeModelId);
|
||||
if (active) saveVisibility(active.fileName, new Set(), null);
|
||||
return { hiddenElementKeys: new Set(), isolatedElementKeys: null, selectedElementKeys: new Set(), visibilityNonce: s.visibilityNonce + 1 };
|
||||
}),
|
||||
measurePoints: [],
|
||||
addMeasurePoint: (p) => set((state) => {
|
||||
const points = [...state.measurePoints, p];
|
||||
|
||||
Reference in New Issue
Block a user