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:
gpt-engineer-app[bot]
2026-05-21 14:55:07 +00:00
8 changed files with 514 additions and 57 deletions
+73 -40
View File
@@ -1,50 +1,83 @@
# Correções no modo Medir
## Problemas # Seleção de elementos IFC — Esconder, Isolar e Exportar GLB
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.
## 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 ## Fluxo do usuário
No `<OrbitControls>` (linha ~792), trocar:
```tsx 1. Usuário ativa botão **"Selecionar"** no ViewerControls (e no XRHud).
enabled={!positionMode && !measureMode} 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: Persistência de `hiddenElementIds`/`isolatedElementIds` por `fileName` em `localStorage` (chave nova `tsxr_visibility_v1`), aplicada ao carregar o modelo.
```tsx
enabled={!positionMode} ### 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: 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`).
- 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).
### 2. Label da medida escala com o zoom e fica ~4x maior ### 7. Performance
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**. - 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`). ## Arquivos tocados
- 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).
```tsx - `src/lib/convertIFC.ts` — marcar `userData.ifcElement`
function MeasurementLabel({ position, text, variant }: { ... }) { - `src/lib/exportSelectionGLB.ts`**novo**
const borderClass = variant === 'success' ? 'border-success/60' : 'border-primary/60'; - `src/stores/useModelStore.ts` — slice de seleção + persistência
const textClass = variant === 'success' ? 'text-success' : 'text-primary'; - `src/components/three/ModelViewer.tsx``SelectionHandler`, highlight, aplicar visibilidade
return ( - `src/components/three/XRControllerMeasure.tsx` — handler de seleção em XR
<Html position={position} center distanceFactor={8} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}> - `src/components/ViewerControls.tsx` — botões desktop
<div className={`rounded bg-card/95 border ${borderClass} px-3 py-1 shadow-lg backdrop-blur-sm`}> - `src/components/XRHud.tsx` — botões XR
<span className={`font-mono text-[16px] font-bold ${textClass} whitespace-nowrap`}>
{text}
</span>
</div>
</Html>
);
}
```
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 - **Não** exportar IFC reduzido (decidido: arquivo é só pra este app, GLB resolve e é trivial).
Store, AR, snap e PointMarker permanecem intocados. - **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.
+75 -1
View File
@@ -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 { 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';
@@ -6,6 +6,9 @@ import { useModelStore } from '@/stores/useModelStore';
import { ScaleSelector } from '@/components/ScaleSelector'; import { ScaleSelector } from '@/components/ScaleSelector';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { exportObjectsAsGLB } from '@/lib/exportSelectionGLB';
import * as THREE from 'three';
import { elementKey, sceneRef } from '@/components/three/ModelViewer';
const WIRE_COLORS = [ const WIRE_COLORS = [
{ label: 'Cinza', value: '#8899aa' }, { label: 'Cinza', value: '#8899aa' },
@@ -28,8 +31,36 @@ export function ViewerControls() {
edgeThresholdAngle, setEdgeThresholdAngle, edgeThresholdAngle, setEdgeThresholdAngle,
positionMode, setPositionMode, positionMode, setPositionMode,
activeModelId, models, activeModelId, models,
selectionMode, setSelectionMode,
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection,
} = useModelStore(); } = useModelStore();
const activeModel = models.find(m => m.id === activeModelId); 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 handleScreenshot = useCallback(() => {
const canvas = document.querySelector('canvas'); const canvas = document.querySelector('canvas');
@@ -207,6 +238,49 @@ export function ViewerControls() {
)} )}
</div> </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 */} {/* Screenshot */}
<Button <Button
+34
View File
@@ -3,6 +3,7 @@ import {
Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera, Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera,
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip, Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip,
ClipboardCheck, X, Crosshair, Magnet, HelpCircle, Maximize2, ClipboardCheck, X, Crosshair, Magnet, HelpCircle, Maximize2,
MousePointerClick, EyeOff, Focus,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
@@ -116,7 +117,12 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
fineTuning, setFineTuning, resetFineTuning, fineTuning, setFineTuning, resetFineTuning,
addScreenshot, addScreenshot,
checklist, checklist,
selectionMode, setSelectionMode,
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
hideSelectedElements, isolateSelectedElements, showAllElements,
} = useModelStore(); } = useModelStore();
const hasSelection = selectedElementKeys.size > 0;
const hasHidden = hiddenElementKeys.size > 0 || isolatedElementKeys !== null;
const approved = checklist.filter(i => i.status === 'approved').length; const approved = checklist.filter(i => i.status === 'approved').length;
const rejected = checklist.filter(i => i.status === 'rejected').length; const rejected = checklist.filter(i => i.status === 'rejected').length;
@@ -373,6 +379,34 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
</Button> </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"> <Button variant="outline" size="icon" className="h-8 w-8" onClick={handleScreenshot} title="Screenshot">
<Camera className="h-3.5 w-3.5" /> <Camera className="h-3.5 w-3.5" />
</Button> </Button>
+160 -1
View File
@@ -10,6 +10,36 @@ interface ModelViewerProps {
url?: string; // legacy, ignored — uses store.models 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() { function LoadingFallback() {
return ( return (
<Html center> <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() { function GridLayer() {
const showGrid = useModelStore((s) => s.showGrid); const showGrid = useModelStore((s) => s.showGrid);
const gridY = useModelStore((s) => s.gridY); const gridY = useModelStore((s) => s.gridY);
@@ -851,6 +1005,7 @@ function PositionDragHandler() {
export function ModelViewerCanvas({ url }: ModelViewerProps) { export function ModelViewerCanvas({ url }: ModelViewerProps) {
const positionMode = useModelStore((s) => s.positionMode); const positionMode = useModelStore((s) => s.positionMode);
const measureMode = useModelStore((s) => s.measureMode); const measureMode = useModelStore((s) => s.measureMode);
const selectionMode = useModelStore((s) => s.selectionMode);
return ( return (
<Canvas <Canvas
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }} camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
@@ -880,6 +1035,9 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
<GridAutoFollower /> <GridAutoFollower />
<PositionDragHandler /> <PositionDragHandler />
<SelectionHandler />
<VisibilityApplier />
<SceneRefCapture />
<OrbitControls <OrbitControls
makeDefault makeDefault
@@ -887,8 +1045,9 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
dampingFactor={0.1} dampingFactor={0.1}
minDistance={0.05} minDistance={0.05}
maxDistance={50} maxDistance={50}
enabled={!positionMode} enabled={!positionMode && !selectionMode}
/> />
</Canvas> </Canvas>
); );
} }
+23 -5
View File
@@ -51,9 +51,10 @@ export function XRControllerMeasure() {
useFrame((_state, _dt, frame: XRFrame | undefined) => { useFrame((_state, _dt, frame: XRFrame | undefined) => {
const measureMode = useModelStore.getState().measureMode; const measureMode = useModelStore.getState().measureMode;
const selectionMode = useModelStore.getState().selectionMode;
if (laserRef.current) laserRef.current.visible = false; if (laserRef.current) laserRef.current.visible = false;
if (tipRef.current) tipRef.current.visible = false; if (tipRef.current) tipRef.current.visible = false;
if (!measureMode || !frame) return; if ((!measureMode && !selectionMode) || !frame) return;
const session = frame.session; const session = frame.session;
const refSpace = gl.xr.getReferenceSpace(); const refSpace = gl.xr.getReferenceSpace();
@@ -199,7 +200,7 @@ export function XRControllerMeasure() {
positions.needsUpdate = true; positions.needsUpdate = true;
laserGeom.current.computeBoundingSphere(); 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); tipColor.current.set(color);
(laserRef.current.material as THREE.LineBasicMaterial).color.set(color); (laserRef.current.material as THREE.LineBasicMaterial).color.set(color);
((tipRef.current.material as THREE.MeshBasicMaterial)).color.copy(tipColor.current); ((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); const trigVal = gp?.buttons?.[0]?.value ?? (gp?.buttons?.[0]?.pressed ? 1 : 0);
if (!trigState.current && trigVal > TRIG_ON) { if (!trigState.current && trigVal > TRIG_ON) {
trigState.current = true; trigState.current = true;
if (snappedPoint) { const st = useModelStore.getState();
useModelStore.getState().addMeasurePoint({ 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, x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z,
}); });
} }
+11 -7
View File
@@ -23,6 +23,13 @@ export async function convertIFCtoGLB(
ifcApi.StreamAllMeshes(modelID, (mesh: WebIFC.FlatMesh) => { ifcApi.StreamAllMeshes(modelID, (mesh: WebIFC.FlatMesh) => {
const placedGeometries = mesh.geometries; 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++) { for (let i = 0; i < placedGeometries.size(); i++) {
const placedGeometry = placedGeometries.get(i); const placedGeometry = placedGeometries.get(i);
@@ -38,8 +45,6 @@ export async function convertIFCtoGLB(
); );
const geometry = new THREE.BufferGeometry(); 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 positionArray = new Float32Array(verts.length / 2);
const normalArray = 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.setAttribute('normal', new THREE.BufferAttribute(normalArray, 3));
geometry.setIndex(new THREE.BufferAttribute(indices, 1)); geometry.setIndex(new THREE.BufferAttribute(indices, 1));
// Get or create material based on color
const color = placedGeometry.color; const color = placedGeometry.color;
const colorKey = (color.x * 255) << 16 | (color.y * 255) << 8 | (color.z * 255); const colorKey = (color.x * 255) << 16 | (color.y * 255) << 8 | (color.z * 255);
let material = materials.get(colorKey); let material = materials.get(colorKey);
if (!material) { if (!material) {
material = new THREE.MeshStandardMaterial({ material = new THREE.MeshStandardMaterial({
@@ -75,16 +78,17 @@ export async function convertIFCtoGLB(
} }
const mesh3 = new THREE.Mesh(geometry, material); const mesh3 = new THREE.Mesh(geometry, material);
mesh3.userData = { ifcId: expressID };
// Apply placement transform
const matrix = new THREE.Matrix4(); const matrix = new THREE.Matrix4();
matrix.fromArray(placedGeometry.flatTransformation); matrix.fromArray(placedGeometry.flatTransformation);
mesh3.applyMatrix4(matrix); mesh3.applyMatrix4(matrix);
scene.add(mesh3); elementGroup.add(mesh3);
ifcGeometry.delete(); ifcGeometry.delete();
} }
if (elementGroup.children.length > 0) scene.add(elementGroup);
}); });
ifcApi.CloseModel(modelID); ifcApi.CloseModel(modelID);
+46
View File
@@ -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);
}
+92 -3
View File
@@ -47,6 +47,30 @@ function savePlacement(fileName: string, fineTuning: FineTuning) {
} catch {} } 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 { export interface ScaleRatio {
label: string; // e.g. "1:50", "2:1", "1:1" label: string; // e.g. "1:50", "2:1", "1:1"
@@ -220,6 +244,23 @@ interface ModelStore {
setMeasureMode: (on: boolean) => void; setMeasureMode: (on: boolean) => void;
positionMode: boolean; positionMode: boolean;
setPositionMode: (on: boolean) => void; 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[]; measurePoints: MeasurePoint[];
addMeasurePoint: (p: MeasurePoint) => void; addMeasurePoint: (p: MeasurePoint) => void;
undoLastMeasurePoint: () => void; undoLastMeasurePoint: () => void;
@@ -307,7 +348,16 @@ export const useModelStore = create<ModelStore>((set, get) => ({
setActiveModel: (id) => { setActiveModel: (id) => {
const state = get(); 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) => ({ toggleModelVisible: (id) => set((state) => ({
@@ -455,9 +505,48 @@ export const useModelStore = create<ModelStore>((set, get) => ({
resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }), resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }),
measureMode: false, 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, 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: [], measurePoints: [],
addMeasurePoint: (p) => set((state) => { addMeasurePoint: (p) => set((state) => {
const points = [...state.measurePoints, p]; const points = [...state.measurePoints, p];