diff --git a/.lovable/plan.md b/.lovable/plan.md index 0c60c7a..f82e664 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -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 `` (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 // chave: `${modelId}:${ifcId|uuid}` +hiddenElementIds: Set +isolatedElementIds: Set | 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 + . ``` -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 ``, 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 ( - -
- - {text} - -
- - ); -} -``` +- `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. diff --git a/src/components/ViewerControls.tsx b/src/components/ViewerControls.tsx index 644b04b..b08a0ef 100644 --- a/src/components/ViewerControls.tsx +++ b/src/components/ViewerControls.tsx @@ -1,4 +1,4 @@ -import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2 } 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() { )} + {/* Selection cluster */} +
+ + + {hasSelection && ( + <> + + + + + + )} + + {hasHiddenOrIsolated && ( + + )} +
+ {/* Screenshot */} )} + {/* Selection */} + + + {hasSelection && ( + <> + + + + )} + {hasHidden && ( + + )} + + diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index 3d2a9f1..a7ad651 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -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 ( @@ -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 ( + + + + ); } diff --git a/src/components/three/XRControllerMeasure.tsx b/src/components/three/XRControllerMeasure.tsx index 03c9c75..d5ef45c 100644 --- a/src/components/three/XRControllerMeasure.tsx +++ b/src/components/three/XRControllerMeasure.tsx @@ -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, }); } diff --git a/src/lib/convertIFC.ts b/src/lib/convertIFC.ts index 75377c4..d175758 100644 --- a/src/lib/convertIFC.ts +++ b/src/lib/convertIFC.ts @@ -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); diff --git a/src/lib/exportSelectionGLB.ts b/src/lib/exportSelectionGLB.ts new file mode 100644 index 0000000..1b91297 --- /dev/null +++ b/src/lib/exportSelectionGLB.ts @@ -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 { + 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((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); +} diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index 00cbfbf..11182e9 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -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 { + try { + const raw = localStorage.getItem(VISIBILITY_KEY); + if (raw) return JSON.parse(raw); + } catch {} + return {}; +} +function saveVisibility(fileName: string, hidden: Set, isolated: Set | 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; isolated: Set | 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; + /** Hidden element keys, per-active-model file (persisted by fileName). */ + hiddenElementKeys: Set; + /** When non-null, only these keys are visible (isolation). */ + isolatedElementKeys: Set | 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((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(), 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((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(), + hiddenElementKeys: new Set(), + 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; + 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; + 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];