809 lines
30 KiB
TypeScript
809 lines
30 KiB
TypeScript
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 });
|
||
},
|
||
}));
|