Files
SteelXR2/src/stores/useModelStore.ts
T
gpt-engineer-app[bot] 383f1fef81 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 14:49:43 +00:00

578 lines
19 KiB
TypeScript

import { create } from 'zustand';
// ── 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<string, StoredPlacement> {
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<string, StoredVisibility> {
try {
const raw = localStorage.getItem(VISIBILITY_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return {};
}
function saveVisibility(fileName: string, hidden: Set<string>, isolated: Set<string> | null) {
try {
const all = loadVisibilityMap();
all[fileName] = { hidden: [...hidden], isolated: isolated ? [...isolated] : null };
localStorage.setItem(VISIBILITY_KEY, JSON.stringify(all));
} catch {}
}
function loadVisibility(fileName: string): { hidden: Set<string>; isolated: Set<string> | null } {
const v = loadVisibilityMap()[fileName];
if (!v) return { hidden: new Set(), isolated: null };
return { hidden: new Set(v.hidden ?? []), isolated: v.isolated ? new Set(v.isolated) : null };
}
export interface ScaleRatio {
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;
}
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<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;
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;
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<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 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();
set({ activeModelId: id, ...syncActive({ models: state.models, activeModelId: id }) });
},
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 })),
positionMode: false,
setPositionMode: (positionMode) => set((s) => ({ positionMode, measureMode: positionMode ? false : s.measureMode })),
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;
const measurement: Measurement = {
id: `m_${Date.now()}`,
pointA: a,
pointB: b,
distanceMM,
};
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) => {
let measurement: Measurement;
if (info.type === 'hole') {
const c: MeasurePoint = { x: info.position[0], y: info.position[1], z: info.position[2] };
measurement = {
id: `m_${Date.now()}`,
pointA: c,
pointB: c,
distanceMM: info.value,
kind: 'hole',
label: ${info.value.toFixed(1)}`,
};
} else {
const a = info.endpoints?.a ?? { x: info.position[0], y: info.position[1], z: info.position[2] };
const b = info.endpoints?.b ?? a;
measurement = {
id: `m_${Date.now()}`,
pointA: a,
pointB: b,
distanceMM: info.value,
kind: 'edge',
label: `${info.value.toFixed(1)} mm`,
};
}
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 });
},
}));