Changes
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
import { Ruler, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
|
||||||
|
export function MeasurementsList() {
|
||||||
|
const { measurements, removeMeasurement } = useModelStore();
|
||||||
|
|
||||||
|
if (measurements.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Medições
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{measurements.map((m, i) => (
|
||||||
|
<div key={m.id} className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Ruler className="h-3.5 w-3.5 text-success" />
|
||||||
|
<span className="font-mono text-xs text-foreground">
|
||||||
|
M{i + 1}: <span className="text-success font-bold">{m.distanceMM.toFixed(1)} mm</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => removeMeasurement(m.id)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Eye, Grid3X3 } from 'lucide-react';
|
import { Eye, Grid3X3, Ruler, Trash2 } from 'lucide-react';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
|
||||||
export function ViewerControls() {
|
export function ViewerControls() {
|
||||||
const { opacity, setOpacity, renderMode, setRenderMode } = useModelStore();
|
const { opacity, setOpacity, renderMode, setRenderMode, measureMode, setMeasureMode, measurements, clearMeasurements, measurePoints } = useModelStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-4 left-4 z-10 flex items-end gap-3">
|
<div className="absolute bottom-4 left-4 z-10 flex items-end gap-3">
|
||||||
@@ -38,6 +38,37 @@ export function ViewerControls() {
|
|||||||
{renderMode === 'wireframe' ? 'Wireframe' : 'Sólido'}
|
{renderMode === 'wireframe' ? 'Wireframe' : 'Sólido'}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Measure tool */}
|
||||||
|
<div className="flex items-end gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant={measureMode ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 h-9"
|
||||||
|
onClick={() => setMeasureMode(!measureMode)}
|
||||||
|
>
|
||||||
|
<Ruler className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{measureMode
|
||||||
|
? measurePoints.length === 0
|
||||||
|
? 'Ponto A…'
|
||||||
|
: 'Ponto B…'
|
||||||
|
: 'Medir'}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{measurements.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={clearMeasurements}
|
||||||
|
title="Limpar medições"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Suspense, useRef, useMemo, useEffect } from 'react';
|
import { Suspense, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||||
import { Canvas, useFrame } from '@react-three/fiber';
|
import { Canvas, useThree, useFrame } from '@react-three/fiber';
|
||||||
import { OrbitControls, useGLTF, Grid, Html } from '@react-three/drei';
|
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
@@ -37,15 +37,12 @@ function GLBModel({ url }: { url: string }) {
|
|||||||
return { size, center };
|
return { size, center };
|
||||||
}, [scene]);
|
}, [scene]);
|
||||||
|
|
||||||
// Apply opacity, wireframe, and checklist color feedback
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Determine overall inspection color
|
|
||||||
const hasRejected = checklist.some(i => i.status === 'rejected');
|
const hasRejected = checklist.some(i => i.status === 'rejected');
|
||||||
const allApproved = checklist.every(i => i.status === 'approved');
|
const allApproved = checklist.every(i => i.status === 'approved');
|
||||||
|
|
||||||
scene.traverse((child) => {
|
scene.traverse((child) => {
|
||||||
if (child instanceof THREE.Mesh) {
|
if (child instanceof THREE.Mesh) {
|
||||||
// Clone material to avoid shared mutations
|
|
||||||
if (Array.isArray(child.material)) {
|
if (Array.isArray(child.material)) {
|
||||||
child.material = child.material.map(m => m.clone());
|
child.material = child.material.map(m => m.clone());
|
||||||
} else {
|
} else {
|
||||||
@@ -58,13 +55,12 @@ function GLBModel({ url }: { url: string }) {
|
|||||||
mat.opacity = opacity;
|
mat.opacity = opacity;
|
||||||
mat.wireframe = renderMode === 'wireframe';
|
mat.wireframe = renderMode === 'wireframe';
|
||||||
mat.needsUpdate = true;
|
mat.needsUpdate = true;
|
||||||
// Color feedback
|
|
||||||
if (hasRejected) {
|
if (hasRejected) {
|
||||||
mat.color.setHSL(0, 0.7, 0.5); // red
|
mat.color.setHSL(0, 0.7, 0.5);
|
||||||
} else if (allApproved) {
|
} else if (allApproved) {
|
||||||
mat.color.setHSL(0.38, 0.7, 0.45); // green
|
mat.color.setHSL(0.38, 0.7, 0.45);
|
||||||
} else {
|
} else {
|
||||||
mat.color.set(0x8899aa); // default steel
|
mat.color.set(0x8899aa);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -89,6 +85,109 @@ function GLBModel({ url }: { url: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sphere marker at a 3D point */
|
||||||
|
function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) {
|
||||||
|
return (
|
||||||
|
<mesh position={position}>
|
||||||
|
<sphereGeometry args={[0.003, 16, 16]} />
|
||||||
|
<meshBasicMaterial color={color} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders all measurements and pending points */
|
||||||
|
function MeasurementOverlay() {
|
||||||
|
const measurements = useModelStore((s) => s.measurements);
|
||||||
|
const measurePoints = useModelStore((s) => s.measurePoints);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Pending first point */}
|
||||||
|
{measurePoints.length === 1 && (
|
||||||
|
<PointMarker position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]} color="#e8a838" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Completed measurements */}
|
||||||
|
{measurements.map((m) => {
|
||||||
|
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
|
||||||
|
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
|
||||||
|
const mid: [number, number, number] = [
|
||||||
|
(a[0] + b[0]) / 2,
|
||||||
|
(a[1] + b[1]) / 2,
|
||||||
|
(a[2] + b[2]) / 2,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group key={m.id}>
|
||||||
|
<PointMarker position={a} color="#22c55e" />
|
||||||
|
<PointMarker position={b} color="#22c55e" />
|
||||||
|
<Line
|
||||||
|
points={[a, b]}
|
||||||
|
color="#22c55e"
|
||||||
|
lineWidth={2}
|
||||||
|
/>
|
||||||
|
<Html position={mid} center distanceFactor={1} style={{ pointerEvents: 'none' }}>
|
||||||
|
<div className="rounded bg-card/95 border border-success/50 px-2 py-0.5 shadow-lg backdrop-blur-sm">
|
||||||
|
<span className="font-mono text-[11px] font-bold text-success whitespace-nowrap">
|
||||||
|
{m.distanceMM.toFixed(1)} mm
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raycasting click handler for measurement mode */
|
||||||
|
function MeasureClickHandler() {
|
||||||
|
const { camera, scene, gl } = useThree();
|
||||||
|
const measureMode = useModelStore((s) => s.measureMode);
|
||||||
|
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
|
||||||
|
const raycaster = useMemo(() => new THREE.Raycaster(), []);
|
||||||
|
const mouse = useMemo(() => new THREE.Vector2(), []);
|
||||||
|
|
||||||
|
const handleClick = useCallback((event: MouseEvent) => {
|
||||||
|
if (!measureMode) return;
|
||||||
|
|
||||||
|
const rect = gl.domElement.getBoundingClientRect();
|
||||||
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
|
||||||
|
raycaster.setFromCamera(mouse, camera);
|
||||||
|
const intersects = raycaster.intersectObjects(scene.children, true);
|
||||||
|
|
||||||
|
// Filter out measurement markers and grid
|
||||||
|
const hit = intersects.find(i => {
|
||||||
|
const obj = i.object;
|
||||||
|
if (obj instanceof THREE.GridHelper) return false;
|
||||||
|
if (obj instanceof THREE.Mesh) {
|
||||||
|
if (obj.geometry instanceof THREE.SphereGeometry) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hit) {
|
||||||
|
addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
|
||||||
|
}
|
||||||
|
}, [measureMode, addMeasurePoint, camera, scene, gl, raycaster, mouse]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = gl.domElement;
|
||||||
|
canvas.addEventListener('click', handleClick);
|
||||||
|
return () => canvas.removeEventListener('click', handleClick);
|
||||||
|
}, [gl, handleClick]);
|
||||||
|
|
||||||
|
// Change cursor when in measure mode
|
||||||
|
useEffect(() => {
|
||||||
|
gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab';
|
||||||
|
return () => { gl.domElement.style.cursor = 'grab'; };
|
||||||
|
}, [measureMode, gl]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
@@ -97,9 +196,7 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
|||||||
frameloop="demand"
|
frameloop="demand"
|
||||||
className="!bg-background"
|
className="!bg-background"
|
||||||
onCreated={({ gl, invalidate }) => {
|
onCreated={({ gl, invalidate }) => {
|
||||||
// Target 72fps for Quest 3 compatibility
|
|
||||||
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||||
// Continuous render for smooth interaction
|
|
||||||
const loop = () => { invalidate(); requestAnimationFrame(loop); };
|
const loop = () => { invalidate(); requestAnimationFrame(loop); };
|
||||||
loop();
|
loop();
|
||||||
}}
|
}}
|
||||||
@@ -112,6 +209,9 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
|||||||
<GLBModel url={url} />
|
<GLBModel url={url} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<MeasurementOverlay />
|
||||||
|
<MeasureClickHandler />
|
||||||
|
|
||||||
<Grid
|
<Grid
|
||||||
infiniteGrid
|
infiniteGrid
|
||||||
cellSize={0.01}
|
cellSize={0.01}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ModelViewerCanvas } from '@/components/three/ModelViewer';
|
|||||||
import { ViewerControls } from '@/components/ViewerControls';
|
import { ViewerControls } from '@/components/ViewerControls';
|
||||||
import { InspectionChecklist } from '@/components/InspectionChecklist';
|
import { InspectionChecklist } from '@/components/InspectionChecklist';
|
||||||
import { FineTuningControls } from '@/components/FineTuningControls';
|
import { FineTuningControls } from '@/components/FineTuningControls';
|
||||||
|
import { MeasurementsList } from '@/components/MeasurementsList';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
@@ -89,6 +90,10 @@ const Viewer = () => {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
<MeasurementsList />
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<InspectionChecklist />
|
<InspectionChecklist />
|
||||||
|
|
||||||
<div className="rounded-lg border bg-muted/50 p-3">
|
<div className="rounded-lg border bg-muted/50 p-3">
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
|
|||||||
type InspectionResult = 'pending' | 'approved' | 'rejected';
|
type InspectionResult = 'pending' | 'approved' | 'rejected';
|
||||||
type RenderMode = 'solid' | 'wireframe';
|
type RenderMode = 'solid' | 'wireframe';
|
||||||
|
|
||||||
|
export interface MeasurePoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Measurement {
|
||||||
|
id: string;
|
||||||
|
pointA: MeasurePoint;
|
||||||
|
pointB: MeasurePoint;
|
||||||
|
distanceMM: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChecklistItem {
|
export interface ChecklistItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -60,6 +73,14 @@ interface ModelStore {
|
|||||||
setChecklistItemNote: (id: string, note: string) => void;
|
setChecklistItemNote: (id: string, note: string) => void;
|
||||||
setChecklistItemAudio: (id: string, audioUrl: string | null) => void;
|
setChecklistItemAudio: (id: string, audioUrl: string | null) => void;
|
||||||
resetChecklist: () => void;
|
resetChecklist: () => void;
|
||||||
|
|
||||||
|
measureMode: boolean;
|
||||||
|
setMeasureMode: (on: boolean) => void;
|
||||||
|
measurePoints: MeasurePoint[];
|
||||||
|
addMeasurePoint: (p: MeasurePoint) => void;
|
||||||
|
measurements: Measurement[];
|
||||||
|
clearMeasurements: () => void;
|
||||||
|
removeMeasurement: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotY: 0 };
|
const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotY: 0 };
|
||||||
@@ -104,4 +125,31 @@ export const useModelStore = create<ModelStore>((set) => ({
|
|||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }),
|
resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }),
|
||||||
|
|
||||||
|
measureMode: false,
|
||||||
|
setMeasureMode: (measureMode) => set({ measureMode, measurePoints: [] }),
|
||||||
|
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 distanceMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
|
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),
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user