import { create } from 'zustand'; import { worldToModelLocal } from '@/lib/modelTransforms'; // ── Persistência de placement (fineTuning + escala) e flags WebXR ───── const PLACEMENT_KEY = 'tsxr_placements_v1'; const XRFEAT_KEY = 'xrFeatures'; export interface XRFeatureFlags { handTracking: boolean; planeDetection: boolean; hitTest: boolean; domOverlay: boolean; anchors: boolean; meshDetection: boolean; depthSensing: boolean; layers: boolean; bodyTracking: boolean; } const DEFAULT_XR_FEATURES: XRFeatureFlags = { handTracking: true, planeDetection: true, hitTest: true, domOverlay: true, anchors: true, meshDetection: true, depthSensing: true, layers: true, bodyTracking: true, }; function loadXRFeatures(): XRFeatureFlags { try { const raw = localStorage.getItem(XRFEAT_KEY); if (raw) return { ...DEFAULT_XR_FEATURES, ...JSON.parse(raw) }; } catch {} return { ...DEFAULT_XR_FEATURES }; } function saveXRFeatures(f: XRFeatureFlags) { try { localStorage.setItem(XRFEAT_KEY, JSON.stringify(f)); } catch {} } interface StoredPlacement { fineTuning: FineTuning; } function loadPlacements(): Record { try { const raw = localStorage.getItem(PLACEMENT_KEY); if (raw) return JSON.parse(raw); } catch {} return {}; } function savePlacement(fileName: string, fineTuning: FineTuning) { try { const all = loadPlacements(); all[fileName] = { fineTuning }; localStorage.setItem(PLACEMENT_KEY, JSON.stringify(all)); } 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" factor: number; // render multiplier: 1:50 => 0.02, 2:1 => 2, 1:1 => 1 } export const SCALE_PRESETS: ScaleRatio[] = [ { label: '10:1', factor: 10 }, { label: '4:1', factor: 4 }, { label: '2:1', factor: 2 }, { label: '1:1', factor: 1 }, { label: '1:2', factor: 1 / 2 }, { label: '1:4', factor: 1 / 4 }, { label: '1:8', factor: 1 / 8 }, { label: '1:10', factor: 1 / 10 }, { label: '1:20', factor: 1 / 20 }, { label: '1:33', factor: 1 / 33 }, { label: '1:50', factor: 1 / 50 }, { label: '1:100', factor: 1 / 100 }, { label: '1:200', factor: 1 / 200 }, ]; interface ModelInfo { fileName: string; fileSize: number; url: string; } interface FineTuning { posX: number; posY: number; posZ: number; rotX: number; rotY: number; rotZ: number; scale: number; } export interface SceneModel { id: string; fileName: string; fileSize: number; url: string; color: string; visible: boolean; locked: boolean; fineTuning: FineTuning; } const MAX_MODELS = 5; const MODEL_COLORS = ['#3b82f6', '#22c55e', '#eab308', '#a855f7', '#ec4899']; type AnchorMode = 'tracking' | 'manual' | 'fine-tuning'; type InspectionResult = 'pending' | 'approved' | 'rejected'; type RenderMode = 'solid' | 'wireframe' | 'edges'; export interface MeasurePoint { x: number; y: number; z: number; } export interface Measurement { id: string; pointA: MeasurePoint; pointB: MeasurePoint; distanceMM: number; /** 'distance' = point-to-point (default), 'hole' = registered diameter at center, 'edge' = registered edge length */ kind?: 'distance' | 'hole' | 'edge'; /** Optional pre-formatted label (e.g. "Ø 12.5"). When absent, UI formats distanceMM. */ label?: string; /** Model the measurement belongs to. When set, pointA/pointB are stored in that * model's local frame so the measurement follows the model when repositioned. */ modelId?: string; } export interface HoverInfo { type: 'hole' | 'edge'; position: [number, number, number]; value: number; // mm (diameter or length) /** For edges: the two endpoints in world coords. */ endpoints?: { a: MeasurePoint; b: MeasurePoint }; } export interface ChecklistItem { id: string; label: string; status: 'pending' | 'approved' | 'rejected'; note: string; audioUrl: string | null; } const defaultChecklist: ChecklistItem[] = [ { id: 'dimensions', label: 'Dimensões Gerais', status: 'pending', note: '', audioUrl: null }, { id: 'plates', label: 'Posição das Placas', status: 'pending', note: '', audioUrl: null }, { id: 'holes', label: 'Diâmetro dos Furos', status: 'pending', note: '', audioUrl: null }, { id: 'weld', label: 'Especificação da Solda', status: 'pending', note: '', audioUrl: null }, ]; const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 }; interface ModelStore { // ── Multi-model scene ───────────── models: SceneModel[]; activeModelId: string | null; addModel: (m: ModelInfo) => string; // returns id removeModel: (id: string) => void; setActiveModel: (id: string | null) => void; toggleModelVisible: (id: string) => void; toggleModelLocked: (id: string) => void; setModelColor: (id: string, color: string) => void; duplicateModel: (id: string) => void; clearAllModels: () => void; maxModels: number; // ── Legacy single-model accessors (proxy to active) ───────────── model: ModelInfo | null; setModel: (model: ModelInfo | null) => void; fineTuning: FineTuning; setFineTuning: (ft: Partial) => void; resetFineTuning: () => void; anchorMode: AnchorMode; setAnchorMode: (mode: AnchorMode) => void; inspectionResult: InspectionResult; setInspectionResult: (result: InspectionResult) => void; xrSupported: boolean | null; setXrSupported: (supported: boolean | null) => void; scaleRatio: ScaleRatio; setScaleRatio: (r: ScaleRatio) => void; /** Incremented whenever the user asks to reset the AR scale; XRGrabbable * watches this and snaps its group.scale back to 1. */ scaleResetNonce: number; resetScale: () => void; opacity: number; setOpacity: (opacity: number) => void; renderMode: RenderMode; setRenderMode: (mode: RenderMode) => void; showGrid: boolean; setShowGrid: (show: boolean) => void; /** World Y position of the grid plane (meters). */ gridY: number; setGridY: (y: number) => void; /** When true, grid follows the bottom of the loaded model automatically. */ gridAutoFollow: boolean; setGridAutoFollow: (b: boolean) => void; /** Nudge grid by delta meters and disable auto-follow. */ nudgeGridY: (delta: number) => void; wireframeColor: string; setWireframeColor: (color: string) => void; wireframeThickness: number; setWireframeThickness: (t: number) => void; edgeThresholdAngle: number; setEdgeThresholdAngle: (angle: number) => void; checklist: ChecklistItem[]; setChecklistItemStatus: (id: string, status: 'approved' | 'rejected') => void; setChecklistItemNote: (id: string, note: string) => void; setChecklistItemAudio: (id: string, audioUrl: string | null) => void; resetChecklist: () => void; measureMode: boolean; 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; measurements: Measurement[]; clearMeasurements: () => void; removeMeasurement: (id: string) => void; undoLastMeasurement: () => void; /** Register the current HoverInfo (hole diameter or edge length) as a saved measurement. */ registerHoverMeasurement: (info: HoverInfo) => void; compareMode: boolean; setCompareMode: (on: boolean) => void; compareImage: string | null; setCompareImage: (url: string | null) => void; snapPoint: MeasurePoint | null; setSnapPoint: (p: MeasurePoint | null) => void; hoverInfo: HoverInfo | null; setHoverInfo: (info: HoverInfo | null) => void; screenshots: string[]; addScreenshot: (dataUrl: string) => void; removeScreenshot: (index: number) => void; clearScreenshots: () => void; // ── WebXR feature toggles (diagnóstico do grid de segurança Quest) ──── xrFeatures: XRFeatureFlags; setXRFeature: (key: keyof XRFeatureFlags, value: boolean) => void; resetXRFeatures: () => void; } /** Sync top-level legacy `model` / `fineTuning` from active SceneModel. */ function syncActive(state: Pick): { model: ModelInfo | null; fineTuning: FineTuning } { const active = state.models.find(m => m.id === state.activeModelId) ?? null; return { model: active ? { fileName: active.fileName, fileSize: active.fileSize, url: active.url } : null, fineTuning: active ? { ...active.fineTuning } : { ...defaultFineTuning }, }; } export const useModelStore = create((set, get) => ({ // ── Multi-model ───────────── models: [], activeModelId: null, maxModels: MAX_MODELS, addModel: (m) => { const state = get(); if (state.models.length >= MAX_MODELS) { return ''; } const id = `mdl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const color = MODEL_COLORS[state.models.length % MODEL_COLORS.length]; const offsetIdx = state.models.length; // Recupera placement anterior salvo (mesmo fileName) — sobrevive a sair/entrar do AR. const saved = loadPlacements()[m.fileName]; const fineTuning: FineTuning = saved?.fineTuning ? { ...saved.fineTuning } : { ...defaultFineTuning, posX: offsetIdx * 0.15 }; const newModel: SceneModel = { id, fileName: m.fileName, fileSize: m.fileSize, url: m.url, color, visible: true, locked: false, fineTuning, }; const models = [...state.models, newModel]; set({ models, activeModelId: id, ...syncActive({ models, activeModelId: id }) }); return id; }, removeModel: (id) => { const state = get(); const models = state.models.filter(m => m.id !== id); const activeModelId = state.activeModelId === id ? (models[0]?.id ?? null) : state.activeModelId; set({ models, activeModelId, ...syncActive({ models, activeModelId }) }); }, setActiveModel: (id) => { const state = get(); 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) => ({ models: state.models.map(m => m.id === id ? { ...m, visible: !m.visible } : m), })), toggleModelLocked: (id) => set((state) => ({ models: state.models.map(m => m.id === id ? { ...m, locked: !m.locked } : m), })), setModelColor: (id, color) => set((state) => ({ models: state.models.map(m => m.id === id ? { ...m, color } : m), })), duplicateModel: (id) => { const state = get(); if (state.models.length >= MAX_MODELS) return; const src = state.models.find(m => m.id === id); if (!src) return; const newId = `mdl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const color = MODEL_COLORS[state.models.length % MODEL_COLORS.length]; const copy: SceneModel = { ...src, id: newId, color, fineTuning: { ...src.fineTuning, posX: src.fineTuning.posX + 0.15 }, }; const models = [...state.models, copy]; set({ models, activeModelId: newId, ...syncActive({ models, activeModelId: newId }) }); }, clearAllModels: () => set({ models: [], activeModelId: null, model: null, fineTuning: { ...defaultFineTuning } }), // ── Legacy single-model proxies ───────────── model: null, setModel: (m) => { if (!m) { set({ models: [], activeModelId: null, model: null, fineTuning: { ...defaultFineTuning } }); return; } // Legacy: replace all models with this single one const id = `mdl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; const newModel: SceneModel = { id, fileName: m.fileName, fileSize: m.fileSize, url: m.url, color: MODEL_COLORS[0], visible: true, locked: false, fineTuning: { ...defaultFineTuning }, }; set({ models: [newModel], activeModelId: id, ...syncActive({ models: [newModel], activeModelId: id }) }); }, fineTuning: { ...defaultFineTuning }, setFineTuning: (ft) => set((state) => { const newFT = { ...state.fineTuning, ...ft }; const activeId = state.activeModelId; const models = activeId ? state.models.map(m => m.id === activeId ? { ...m, fineTuning: newFT } : m) : state.models; // Persiste placement por fileName para sobreviver à saída/re-entrada do AR. const active = models.find(m => m.id === activeId); if (active) savePlacement(active.fileName, newFT); return { fineTuning: newFT, models }; }), resetFineTuning: () => set((state) => { const reset = { ...defaultFineTuning }; const activeId = state.activeModelId; const models = activeId ? state.models.map(m => m.id === activeId ? { ...m, fineTuning: reset } : m) : state.models; const active = models.find(m => m.id === activeId); if (active) savePlacement(active.fileName, reset); return { fineTuning: reset, models }; }), anchorMode: 'manual', setAnchorMode: (anchorMode) => set({ anchorMode }), inspectionResult: 'pending', setInspectionResult: (inspectionResult) => set({ inspectionResult }), xrSupported: null, setXrSupported: (xrSupported) => set({ xrSupported }), scaleRatio: SCALE_PRESETS.find(p => p.label === '1:1')!, setScaleRatio: (scaleRatio) => set({ scaleRatio }), scaleResetNonce: 0, resetScale: () => set((state) => { const oneToOne = SCALE_PRESETS.find(p => p.label === '1:1')!; const activeId = state.activeModelId; const models = activeId ? state.models.map(m => m.id === activeId ? { ...m, fineTuning: { ...m.fineTuning, scale: 1 } } : m) : state.models; const active = models.find(m => m.id === activeId); const fineTuning = active ? { ...active.fineTuning } : { ...state.fineTuning, scale: 1 }; if (active) savePlacement(active.fileName, fineTuning); return { scaleRatio: oneToOne, models, fineTuning, scaleResetNonce: state.scaleResetNonce + 1, }; }), opacity: 1, setOpacity: (opacity) => set({ opacity }), renderMode: 'solid', setRenderMode: (renderMode) => set({ renderMode }), showGrid: false, setShowGrid: (showGrid) => set({ showGrid }), gridY: -0.09, setGridY: (gridY) => set({ gridY, gridAutoFollow: false }), gridAutoFollow: true, setGridAutoFollow: (gridAutoFollow) => set({ gridAutoFollow }), nudgeGridY: (delta) => set((s) => ({ gridY: s.gridY + delta, gridAutoFollow: false })), wireframeColor: '#8899aa', setWireframeColor: (wireframeColor) => set({ wireframeColor }), wireframeThickness: 1, setWireframeThickness: (wireframeThickness) => set({ wireframeThickness }), edgeThresholdAngle: 15, setEdgeThresholdAngle: (edgeThresholdAngle) => set({ edgeThresholdAngle }), checklist: defaultChecklist.map(i => ({ ...i })), setChecklistItemStatus: (id, status) => set((state) => ({ checklist: state.checklist.map(item => item.id === id ? { ...item, status } : item ), })), setChecklistItemNote: (id, note) => set((state) => ({ checklist: state.checklist.map(item => item.id === id ? { ...item, note } : item ), })), setChecklistItemAudio: (id, audioUrl) => set((state) => ({ checklist: state.checklist.map(item => item.id === id ? { ...item, audioUrl } : item ), })), resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }), measureMode: false, 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, selectionMode: positionMode ? false : s.selectionMode })), // ── Selection state ───────────────────────────────────── selectionMode: false, setSelectionMode: (on) => set((s) => ({ selectionMode: on, measureMode: on ? false : s.measureMode, positionMode: 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]; if (points.length === 2) { const [a, b] = points; const dx = (a.x - b.x) * 1000; const dy = (a.y - b.y) * 1000; const dz = (a.z - b.z) * 1000; const distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz); const factor = state.scaleRatio?.factor ?? 1; const distanceMM = distanceWorldMM / factor; // Convert world points to active model's local frame so they follow the model. const modelId = state.activeModelId ?? undefined; const aConv = modelId ? worldToModelLocal(modelId, a) : null; const bConv = modelId ? worldToModelLocal(modelId, b) : null; const attached = !!(aConv && bConv); const measurement: Measurement = { id: `m_${Date.now()}`, pointA: attached ? aConv! : a, pointB: attached ? bConv! : b, distanceMM, modelId: attached ? modelId : undefined, }; return { measurePoints: [], measurements: [...state.measurements, measurement] }; } return { measurePoints: points }; }), measurements: [], clearMeasurements: () => set({ measurements: [], measurePoints: [] }), removeMeasurement: (id) => set((state) => ({ measurements: state.measurements.filter(m => m.id !== id), })), undoLastMeasurePoint: () => set((state) => ({ measurePoints: state.measurePoints.slice(0, -1), })), undoLastMeasurement: () => set((state) => { if (state.measurePoints.length > 0) { return { measurePoints: state.measurePoints.slice(0, -1) }; } return { measurements: state.measurements.slice(0, -1) }; }), registerHoverMeasurement: (info) => set((state) => { const modelId = state.activeModelId ?? undefined; const toLocal = (p: MeasurePoint): { point: MeasurePoint; attached: boolean } => { const conv = modelId ? worldToModelLocal(modelId, p) : null; return conv ? { point: conv, attached: true } : { point: p, attached: false }; }; let measurement: Measurement; let attached = false; if (info.type === 'hole') { const cw: MeasurePoint = { x: info.position[0], y: info.position[1], z: info.position[2] }; const c = toLocal(cw); attached = c.attached; measurement = { id: `m_${Date.now()}`, pointA: c.point, pointB: c.point, distanceMM: info.value, kind: 'hole', label: `Ø ${info.value.toFixed(1)}`, modelId: attached ? modelId : undefined, }; } else { const aw = info.endpoints?.a ?? { x: info.position[0], y: info.position[1], z: info.position[2] }; const bw = info.endpoints?.b ?? aw; const a = toLocal(aw); const b = toLocal(bw); attached = a.attached && b.attached; measurement = { id: `m_${Date.now()}`, pointA: attached ? a.point : aw, pointB: attached ? b.point : bw, distanceMM: info.value, kind: 'edge', label: `${info.value.toFixed(1)} mm`, modelId: attached ? modelId : undefined, }; } return { measurements: [...state.measurements, measurement], hoverInfo: null }; }), compareMode: false, setCompareMode: (compareMode) => set({ compareMode }), compareImage: null, setCompareImage: (compareImage) => set({ compareImage }), snapPoint: null, setSnapPoint: (snapPoint) => set({ snapPoint }), hoverInfo: null, setHoverInfo: (hoverInfo) => set({ hoverInfo }), screenshots: [], addScreenshot: (dataUrl) => set((state) => ({ screenshots: [...state.screenshots, dataUrl] })), removeScreenshot: (index) => set((state) => ({ screenshots: state.screenshots.filter((_, i) => i !== index), })), clearScreenshots: () => set({ screenshots: [] }), xrFeatures: loadXRFeatures(), setXRFeature: (key, value) => set((state) => { const next = { ...state.xrFeatures, [key]: value }; saveXRFeatures(next); return { xrFeatures: next }; }), resetXRFeatures: () => { const next = { ...DEFAULT_XR_FEATURES }; saveXRFeatures(next); set({ xrFeatures: next }); }, }));