Files
SteelXR/src/stores/useModelStore.ts
T

809 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { create } from 'zustand';
import { getModelWorldScaleFactor, worldToModelLocal } from '@/lib/modelTransforms';
import { sendRemoteLog } from '@/lib/remoteLogger';
// ── Persistência de placement (fineTuning + escala) e flags WebXR ─────
const PLACEMENT_KEY = 'tsxr_placements_v1';
const XRFEAT_KEY = 'xrFeatures_v2';
const CAMERA_MODE_KEY = 'tsxr_camera_mode_v1';
function loadCameraMode(): 'ortho' | 'persp' {
try {
const raw = localStorage.getItem(CAMERA_MODE_KEY);
if (raw === 'persp' || raw === 'ortho') return raw;
} catch (e) { /* ignore */ }
return 'ortho';
}
function saveCameraMode(m: 'ortho' | 'persp') {
try { localStorage.setItem(CAMERA_MODE_KEY, m); } catch (e) { /* ignore */ }
}
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: false,
depthSensing: false,
layers: false,
bodyTracking: false,
};
function loadXRFeatures(): XRFeatureFlags {
try {
const raw = localStorage.getItem(XRFEAT_KEY);
if (raw) return { ...DEFAULT_XR_FEATURES, ...JSON.parse(raw) };
} catch (e) { /* ignore */ }
return { ...DEFAULT_XR_FEATURES };
}
function saveXRFeatures(f: XRFeatureFlags) {
try { localStorage.setItem(XRFEAT_KEY, JSON.stringify(f)); } catch (e) { /* ignore */ }
}
interface StoredPlacement { fineTuning: FineTuning; calibrationQuat?: [number, number, number, number] | null; }
function loadPlacements(): Record<string, StoredPlacement> {
try {
const raw = localStorage.getItem(PLACEMENT_KEY);
if (raw) return JSON.parse(raw);
} catch (e) { /* ignore */ }
return {};
}
function savePlacement(fileName: string, fineTuning: FineTuning, calibrationQuat?: [number, number, number, number] | null) {
try {
const all = loadPlacements();
const prev = all[fileName] ?? { fineTuning };
all[fileName] = {
fineTuning,
calibrationQuat: calibrationQuat === undefined ? prev.calibrationQuat ?? null : calibrationQuat,
};
localStorage.setItem(PLACEMENT_KEY, JSON.stringify(all));
} catch (e) { /* ignore */ }
}
// ── 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 (e) { /* ignore */ }
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 (e) { /* ignore */ }
}
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 };
}
const IFC_MAT_COLORS_KEY = 'tsxr_ifc_mat_colors_v1';
function loadIfcMaterialColors(): boolean {
try {
const raw = localStorage.getItem(IFC_MAT_COLORS_KEY);
return raw !== 'false';
} catch (e) { /* ignore */ }
return true;
}
function saveIfcMaterialColors(enabled: boolean) {
try { localStorage.setItem(IFC_MAT_COLORS_KEY, String(enabled)); } catch (e) { /* ignore */ }
}
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;
/** Local rotation (xyzw) that aligns the model's intrinsic axes to world axes. */
calibrationQuat: [number, number, number, number] | null;
}
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;
/** Model hit by this point, used so saved dimensions attach to the right piece. */
modelId?: string;
}
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)
modelId?: string;
/** 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;
/** Calibração (rotação local) ↔ ViewCube. Aplicada antes do fineTuning rotation. */
setCalibration: (id: string, quat: [number, number, number, number] | null) => void;
// ── Legacy single-model accessors (proxy to active) ─────────────
model: ModelInfo | null;
setModel: (model: ModelInfo | null) => void;
fineTuning: FineTuning;
setFineTuning: (ft: Partial<FineTuning>) => 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;
/** Camera projection used in the viewer. Ortho is the default for CAD-style work. */
cameraMode: 'ortho' | 'persp';
setCameraMode: (m: 'ortho' | 'persp') => 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;
/** When true, the next click on a model face sets grid Y to that point. */
gridCalibMode: boolean;
setGridCalibMode: (b: boolean) => void;
/** When true, the VR controller/headset hit-tests real world surfaces to place grid & models. */
gridLandingMode: boolean;
setGridLandingMode: (b: boolean) => void;
/** Section/clipping cut tool (X/Y/Z axis-aligned planes in world space). */
sectionEnabled: { x: boolean; y: boolean; z: boolean };
sectionInvert: { x: boolean; y: boolean; z: boolean };
/** Cut level in meters (world coordinates) per axis. */
sectionLevel: { x: number; y: number; z: number };
sectionPanelOpen: boolean;
/** Opacity of the floating section panel (0.2 1). */
sectionPanelOpacity: number;
setSectionEnabled: (axis: 'x' | 'y' | 'z', on: boolean) => void;
setSectionInvert: (axis: 'x' | 'y' | 'z', on: boolean) => void;
setSectionLevel: (axis: 'x' | 'y' | 'z', v: number) => void;
setSectionPanelOpen: (open: boolean) => void;
setSectionPanelOpacity: (o: number) => void;
resetSection: () => void;
wireframeColor: string;
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;
walkMode: boolean;
setWalkMode: (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;
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;
ifcMaterialColors: boolean;
setIfcMaterialColors: (enabled: boolean) => void;
}
/** Sync top-level legacy `model` / `fineTuning` from active SceneModel. */
function syncActive(state: Pick<ModelStore, 'models' | 'activeModelId'>): { 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<ModelStore>((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 calibrationQuat = saved?.calibrationQuat ?? null;
const newModel: SceneModel = {
id,
fileName: m.fileName,
fileSize: m.fileSize,
url: m.url,
color,
visible: true,
locked: false,
fineTuning,
calibrationQuat,
};
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<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) => ({
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 } }),
setCalibration: (id, quat) => set((state) => {
const models = state.models.map(m => {
if (m.id !== id) return m;
savePlacement(m.fileName, m.fineTuning, quat);
return { ...m, calibrationQuat: quat };
});
return { models };
}),
// ── 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 },
calibrationQuat: null,
};
set({ models: [newModel], activeModelId: id, ...syncActive({ models: [newModel], activeModelId: id }) });
},
fineTuning: { ...defaultFineTuning },
setFineTuning: (ft) => set((state) => {
const activeId = state.activeModelId;
const activeBefore = state.models.find(m => m.id === activeId);
// Lock guard: when the active model is locked, ignore any movement/scale change.
if (activeBefore?.locked) return {};
const newFT = { ...state.fineTuning, ...ft };
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 activeId = state.activeModelId;
const activeBefore = state.models.find(m => m.id === activeId);
if (activeBefore?.locked) return {};
const reset = { ...defaultFineTuning };
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((state) => {
const activeBefore = state.models.find(m => m.id === state.activeModelId);
if (activeBefore?.locked) return {};
return { scaleRatio };
}),
scaleResetNonce: 0,
resetScale: () => set((state) => {
const activeId = state.activeModelId;
const activeBefore = state.models.find(m => m.id === activeId);
if (activeBefore?.locked) return {};
const oneToOne = SCALE_PRESETS.find(p => p.label === '1:1')!;
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 }),
cameraMode: loadCameraMode(),
setCameraMode: (cameraMode) => { saveCameraMode(cameraMode); set({ cameraMode }); },
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 })),
gridCalibMode: false,
setGridCalibMode: (gridCalibMode) => set({ gridCalibMode }),
gridLandingMode: false,
setGridLandingMode: (gridLandingMode) => set({ gridLandingMode }),
sectionEnabled: { x: false, y: false, z: false },
sectionInvert: { x: false, y: false, z: false },
sectionLevel: { x: 0, y: 0, z: 0 },
sectionPanelOpen: false,
sectionPanelOpacity: 0.9,
setSectionEnabled: (axis, on) => set((s) => ({ sectionEnabled: { ...s.sectionEnabled, [axis]: on } })),
setSectionInvert: (axis, on) => set((s) => ({ sectionInvert: { ...s.sectionInvert, [axis]: on } })),
setSectionLevel: (axis, v) => set((s) => ({ sectionLevel: { ...s.sectionLevel, [axis]: v } })),
setSectionPanelOpen: (sectionPanelOpen) => set({ sectionPanelOpen }),
setSectionPanelOpacity: (sectionPanelOpacity) => set({ sectionPanelOpacity }),
resetSection: () => set({
sectionEnabled: { x: false, y: false, z: false },
sectionInvert: { x: false, y: false, z: false },
sectionLevel: { x: 0, y: 0, z: 0 },
}),
wireframeColor: '#00f3ff',
setWireframeColor: (wireframeColor) => set({ wireframeColor }),
wireframeThickness: 2,
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, walkMode: false })),
positionMode: false,
setPositionMode: (positionMode) => set((s) => ({ positionMode, measureMode: positionMode ? false : s.measureMode, selectionMode: positionMode ? false : s.selectionMode, walkMode: false })),
walkMode: false,
setWalkMode: (walkMode) => {
set({ walkMode });
if (walkMode) set({ positionMode: false, measureMode: false, selectionMode: false, cameraMode: 'persp' });
},
// ── 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<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) => {
sendRemoteLog('info', `addMeasurePoint acionado: ponto atual ${state.measurePoints.length + 1}`, p);
const points = [...state.measurePoints, p];
if (points.length === 2) {
const [a, b] = points;
const modelId = (a.modelId && a.modelId === b.modelId ? a.modelId : state.activeModelId) ?? undefined;
const aConv = modelId ? worldToModelLocal(modelId, a) : null;
const bConv = modelId ? worldToModelLocal(modelId, b) : null;
const attached = !!(aConv && bConv);
const pa = attached ? aConv! : a;
const pb = attached ? bConv! : b;
const dx = (pa.x - pb.x) * 1000;
const dy = (pa.y - pb.y) * 1000;
const dz = (pa.z - pb.z) * 1000;
const distanceMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
sendRemoteLog('info', `addMeasurePoint: calculando distância`, {
distanceMM,
modelId,
attached
});
const measurement: Measurement = {
id: `m_${Date.now()}`,
pointA: pa,
pointB: pb,
distanceMM,
modelId: attached ? modelId : undefined,
};
sendRemoteLog('info', `addMeasurePoint: medição gerada com sucesso`, measurement);
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 = info.modelId ?? state.activeModelId ?? undefined;
const modelScale = modelId ? getModelWorldScaleFactor(modelId) : 1;
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;
const valueMM = attached ? info.value / modelScale : info.value;
measurement = {
id: `m_${Date.now()}`,
pointA: c.point,
pointB: c.point,
distanceMM: valueMM,
kind: 'hole',
label: ${valueMM.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;
const pa = attached ? a.point : aw;
const pb = attached ? b.point : bw;
const dx = (pa.x - pb.x) * 1000;
const dy = (pa.y - pb.y) * 1000;
const dz = (pa.z - pb.z) * 1000;
const valueMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
measurement = {
id: `m_${Date.now()}`,
pointA: pa,
pointB: pb,
distanceMM: valueMM,
kind: 'edge',
label: `${valueMM.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 });
},
ifcMaterialColors: loadIfcMaterialColors(),
setIfcMaterialColors: (enabled: boolean) => {
saveIfcMaterialColors(enabled);
set({ ifcMaterialColors: enabled });
},
}));