From d367d2d1c962a1669ecb30386e34441265cd4e3c Mon Sep 17 00:00:00 2001
From: "gpt-engineer-app[bot]"
<159125892+gpt-engineer-app[bot]@users.noreply.github.com>
Date: Fri, 27 Feb 2026 02:58:18 +0000
Subject: [PATCH] Changes
---
src/components/three/ModelViewer.tsx | 204 ++++++++++++++++-
src/components/three/SmartMeasure.ts | 318 +++++++++++++++++++++++++++
src/stores/useModelStore.ts | 18 ++
3 files changed, 536 insertions(+), 4 deletions(-)
create mode 100644 src/components/three/SmartMeasure.ts
diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx
index 3e0bd59..4832f93 100644
--- a/src/components/three/ModelViewer.tsx
+++ b/src/components/three/ModelViewer.tsx
@@ -4,6 +4,7 @@ import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
import { Loader2 } from 'lucide-react';
import * as THREE from 'three';
import { useModelStore } from '@/stores/useModelStore';
+import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment } from './SmartMeasure';
interface ModelViewerProps {
url: string;
@@ -140,13 +141,43 @@ function PointMarker({ position, color = '#e8a838' }: { position: [number, numbe
);
}
-/** Renders all measurements and pending points */
+/** Snap ring indicator */
+function SnapRing({ position }: { position: [number, number, number] }) {
+ return (
+
+
+
+
+ );
+}
+
+/** Renders all measurements, snap point, and hover info */
function MeasurementOverlay() {
const measurements = useModelStore((s) => s.measurements);
const measurePoints = useModelStore((s) => s.measurePoints);
+ const snapPoint = useModelStore((s) => s.snapPoint);
+ const hoverInfo = useModelStore((s) => s.hoverInfo);
+ const measureMode = useModelStore((s) => s.measureMode);
return (
<>
+ {/* Snap indicator */}
+ {measureMode && snapPoint && (
+
+ )}
+
+ {/* Hover auto-detect tooltip */}
+ {hoverInfo && (
+
+
+
+ {hoverInfo.type === 'hole' ? '⌀ ' : ''}
+ {hoverInfo.value.toFixed(1)} mm
+
+
+
+ )}
+
{/* Pending first point */}
{measurePoints.length === 1 && (
@@ -185,17 +216,180 @@ function MeasurementOverlay() {
);
}
-/** Raycasting click handler for measurement mode */
+/** Smart vertex snap on pointer move */
+function SmartSnapHandler() {
+ const { camera, scene, gl } = useThree();
+ const measureMode = useModelStore((s) => s.measureMode);
+ const setSnapPoint = useModelStore((s) => s.setSnapPoint);
+ const raycaster = useMemo(() => new THREE.Raycaster(), []);
+ const mouse = useMemo(() => new THREE.Vector2(), []);
+ const mouseRef = useRef({ x: 0, y: 0 });
+
+ useEffect(() => {
+ const onMove = (e: MouseEvent) => {
+ const rect = gl.domElement.getBoundingClientRect();
+ mouseRef.current.x = e.clientX - rect.left;
+ mouseRef.current.y = e.clientY - rect.top;
+ mouse.x = (mouseRef.current.x / rect.width) * 2 - 1;
+ mouse.y = -(mouseRef.current.y / rect.height) * 2 + 1;
+ };
+ gl.domElement.addEventListener('pointermove', onMove);
+ return () => gl.domElement.removeEventListener('pointermove', onMove);
+ }, [gl, mouse]);
+
+ useFrame(() => {
+ if (!measureMode) {
+ setSnapPoint(null);
+ return;
+ }
+
+ raycaster.setFromCamera(mouse, camera);
+ const intersects = raycaster.intersectObjects(scene.children, true);
+ const hit = intersects.find(i => {
+ const obj = i.object;
+ if (obj instanceof THREE.GridHelper) return false;
+ if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.SphereGeometry) return false;
+ if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.RingGeometry) return false;
+ if (obj.userData.__edgeLine) return false;
+ return obj instanceof THREE.Mesh;
+ });
+
+ if (hit && hit.object instanceof THREE.Mesh) {
+ const canvas = gl.domElement;
+ const snap = findNearestVertex(
+ hit.object, hit.point, camera,
+ { width: canvas.clientWidth, height: canvas.clientHeight },
+ 10
+ );
+ if (snap) {
+ setSnapPoint({ x: snap.x, y: snap.y, z: snap.z });
+ } else {
+ setSnapPoint(null);
+ }
+ } else {
+ setSnapPoint(null);
+ }
+ });
+
+ return null;
+}
+
+/** Hover detector for auto-detect hole diameter and edge length */
+function HoverDetector() {
+ const { camera, scene, gl } = useThree();
+ const measureMode = useModelStore((s) => s.measureMode);
+ const setHoverInfo = useModelStore((s) => s.setHoverInfo);
+ const raycaster = useMemo(() => new THREE.Raycaster(), []);
+ const mouse = useMemo(() => new THREE.Vector2(), []);
+ const hoverTimer = useRef | null>(null);
+ const lastHitKey = useRef('');
+
+ useEffect(() => {
+ if (measureMode) {
+ setHoverInfo(null);
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
+ return;
+ }
+
+ const onMove = (e: MouseEvent) => {
+ const rect = gl.domElement.getBoundingClientRect();
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
+
+ raycaster.setFromCamera(mouse, camera);
+ const intersects = raycaster.intersectObjects(scene.children, true);
+ const hit = intersects.find(i => {
+ const obj = i.object;
+ if (obj instanceof THREE.GridHelper) return false;
+ if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.SphereGeometry) return false;
+ if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.RingGeometry) return false;
+ if (obj.userData.__edgeLine) return false;
+ return obj instanceof THREE.Mesh;
+ });
+
+ if (!hit || !(hit.object instanceof THREE.Mesh)) {
+ lastHitKey.current = '';
+ setHoverInfo(null);
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
+ return;
+ }
+
+ // Stability check – same approximate position for debounce
+ const key = `${hit.point.x.toFixed(3)},${hit.point.y.toFixed(3)},${hit.point.z.toFixed(3)}`;
+ if (key === lastHitKey.current) return; // timer already running
+ lastHitKey.current = key;
+ setHoverInfo(null);
+
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
+ hoverTimer.current = setTimeout(() => {
+ if (!(hit.object instanceof THREE.Mesh)) return;
+ const canvas = gl.domElement;
+ const canvasSize = { width: canvas.clientWidth, height: canvas.clientHeight };
+
+ // Try hole detection first
+ if (hit.faceIndex !== undefined) {
+ const hole = detectHoleAtFace(hit.object, hit.faceIndex);
+ if (hole) {
+ setHoverInfo({
+ type: 'hole',
+ position: [hole.center.x, hole.center.y, hole.center.z],
+ value: hole.diameterMM,
+ });
+ return;
+ }
+ }
+
+ // Try edge detection
+ const edge = findNearestEdgeSegment(hit.object, hit.point, camera, canvasSize, 15);
+ if (edge) {
+ setHoverInfo({
+ type: 'edge',
+ position: [edge.midpoint.x, edge.midpoint.y, edge.midpoint.z],
+ value: edge.lengthMM,
+ });
+ return;
+ }
+
+ setHoverInfo(null);
+ }, 1000);
+ };
+
+ const onLeave = () => {
+ lastHitKey.current = '';
+ setHoverInfo(null);
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
+ };
+
+ gl.domElement.addEventListener('pointermove', onMove);
+ gl.domElement.addEventListener('pointerleave', onLeave);
+ return () => {
+ gl.domElement.removeEventListener('pointermove', onMove);
+ gl.domElement.removeEventListener('pointerleave', onLeave);
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
+ };
+ }, [measureMode, camera, scene, gl, raycaster, mouse, setHoverInfo]);
+
+ return null;
+}
+
+/** Raycasting click handler for measurement mode – uses snap point when available */
function MeasureClickHandler() {
const { camera, scene, gl } = useThree();
const measureMode = useModelStore((s) => s.measureMode);
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
+ const snapPoint = useModelStore((s) => s.snapPoint);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []);
const handleClick = useCallback((event: MouseEvent) => {
if (!measureMode) return;
+ // Use snap point if available
+ if (snapPoint) {
+ addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
+ 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;
@@ -203,12 +397,12 @@ function MeasureClickHandler() {
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;
+ if (obj.geometry instanceof THREE.RingGeometry) return false;
}
return true;
});
@@ -216,7 +410,7 @@ function MeasureClickHandler() {
if (hit) {
addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
}
- }, [measureMode, addMeasurePoint, camera, scene, gl, raycaster, mouse]);
+ }, [measureMode, addMeasurePoint, snapPoint, camera, scene, gl, raycaster, mouse]);
useEffect(() => {
const canvas = gl.domElement;
@@ -274,6 +468,8 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
+
+
diff --git a/src/components/three/SmartMeasure.ts b/src/components/three/SmartMeasure.ts
new file mode 100644
index 0000000..33b1792
--- /dev/null
+++ b/src/components/three/SmartMeasure.ts
@@ -0,0 +1,318 @@
+import * as THREE from 'three';
+
+const _v = new THREE.Vector3();
+const _projected = new THREE.Vector3();
+
+/**
+ * Find the nearest vertex in a mesh to a world-space point,
+ * returning it only if it's within `thresholdPx` pixels on screen.
+ */
+export function findNearestVertex(
+ mesh: THREE.Mesh,
+ worldPoint: THREE.Vector3,
+ camera: THREE.Camera,
+ canvasSize: { width: number; height: number },
+ thresholdPx: number = 10
+): THREE.Vector3 | null {
+ const geo = mesh.geometry;
+ const posAttr = geo.attributes.position;
+ if (!posAttr) return null;
+
+ // Project hit point to screen
+ const hitScreen = worldPoint.clone().project(camera);
+ const hitX = (hitScreen.x * 0.5 + 0.5) * canvasSize.width;
+ const hitY = (-hitScreen.y * 0.5 + 0.5) * canvasSize.height;
+
+ let bestDist = Infinity;
+ let bestVertex: THREE.Vector3 | null = null;
+
+ for (let i = 0; i < posAttr.count; i++) {
+ _v.fromBufferAttribute(posAttr, i);
+ _v.applyMatrix4(mesh.matrixWorld);
+
+ _projected.copy(_v).project(camera);
+ const sx = (_projected.x * 0.5 + 0.5) * canvasSize.width;
+ const sy = (-_projected.y * 0.5 + 0.5) * canvasSize.height;
+
+ const dx = sx - hitX;
+ const dy = sy - hitY;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestVertex = _v.clone();
+ }
+ }
+
+ if (bestDist <= thresholdPx && bestVertex) {
+ return bestVertex;
+ }
+ return null;
+}
+
+/**
+ * Detect if the face under the cursor belongs to a cylindrical hole.
+ * Uses face normals around the hit to detect radial patterns → circle fit.
+ * Returns diameter in mm, or null if not a hole.
+ */
+export function detectHoleAtFace(
+ mesh: THREE.Mesh,
+ faceIndex: number
+): { center: THREE.Vector3; diameterMM: number } | null {
+ const geo = mesh.geometry;
+ const posAttr = geo.attributes.position;
+ const normalAttr = geo.attributes.normal;
+ const indexAttr = geo.index;
+
+ if (!posAttr || !normalAttr || !indexAttr) return null;
+
+ // Get hit face normal
+ const i0 = indexAttr.getX(faceIndex * 3);
+ const i1 = indexAttr.getX(faceIndex * 3 + 1);
+ const i2 = indexAttr.getX(faceIndex * 3 + 2);
+
+ const hitNormal = new THREE.Vector3();
+ const n0 = new THREE.Vector3().fromBufferAttribute(normalAttr, i0);
+ const n1 = new THREE.Vector3().fromBufferAttribute(normalAttr, i1);
+ const n2 = new THREE.Vector3().fromBufferAttribute(normalAttr, i2);
+ hitNormal.addVectors(n0, n1).add(n2).normalize();
+
+ const hitCenter = new THREE.Vector3();
+ const p0 = new THREE.Vector3().fromBufferAttribute(posAttr, i0);
+ const p1 = new THREE.Vector3().fromBufferAttribute(posAttr, i1);
+ const p2 = new THREE.Vector3().fromBufferAttribute(posAttr, i2);
+ hitCenter.addVectors(p0, p1).add(p2).divideScalar(3);
+
+ // Collect neighboring faces with similar "cylindrical" normal pattern
+ // Cylindrical faces have normals perpendicular to the cylinder axis
+ // We look for faces whose normals are roughly perpendicular to each other
+ // but share a common axis (the hole axis)
+
+ const faceCount = indexAttr.count / 3;
+ const cylinderVertices: THREE.Vector3[] = [];
+ const faceNormals: THREE.Vector3[] = [];
+
+ // Threshold for proximity (local search)
+ const searchRadius = 0.05; // 50mm in model units
+
+ for (let f = 0; f < faceCount; f++) {
+ const fi0 = indexAttr.getX(f * 3);
+ const fi1 = indexAttr.getX(f * 3 + 1);
+ const fi2 = indexAttr.getX(f * 3 + 2);
+
+ const fp0 = new THREE.Vector3().fromBufferAttribute(posAttr, fi0);
+ const fp1 = new THREE.Vector3().fromBufferAttribute(posAttr, fi1);
+ const fp2 = new THREE.Vector3().fromBufferAttribute(posAttr, fi2);
+
+ const fc = new THREE.Vector3().addVectors(fp0, fp1).add(fp2).divideScalar(3);
+ if (fc.distanceTo(hitCenter) > searchRadius) continue;
+
+ const fn = new THREE.Vector3();
+ const fn0 = new THREE.Vector3().fromBufferAttribute(normalAttr, fi0);
+ const fn1 = new THREE.Vector3().fromBufferAttribute(normalAttr, fi1);
+ const fn2 = new THREE.Vector3().fromBufferAttribute(normalAttr, fi2);
+ fn.addVectors(fn0, fn1).add(fn2).normalize();
+
+ // For cylindrical surfaces, normals should be roughly perpendicular to hole axis
+ // and the dot product between hit normal and face normal reveals if they share curvature
+ const dot = Math.abs(hitNormal.dot(fn));
+ // Cylindrical faces: normals vary (dot < 0.95) but aren't opposite (dot > -0.5)
+ if (dot < 0.98) {
+ cylinderVertices.push(fp0, fp1, fp2);
+ faceNormals.push(fn);
+ }
+ }
+
+ if (cylinderVertices.length < 9) return null; // Need at least 3 faces
+
+ // Try to find a common axis and fit a circle
+ // Estimate axis as cross product of two differing normals
+ let axis: THREE.Vector3 | null = null;
+ for (let i = 1; i < faceNormals.length; i++) {
+ const cross = new THREE.Vector3().crossVectors(faceNormals[0], faceNormals[i]);
+ if (cross.length() > 0.1) {
+ axis = cross.normalize();
+ break;
+ }
+ }
+ if (!axis) return null;
+
+ // Project vertices onto plane perpendicular to axis
+ // Use least-squares circle fit on 2D projections
+ const basisU = new THREE.Vector3();
+ const basisV = new THREE.Vector3();
+
+ // Create orthonormal basis
+ if (Math.abs(axis.x) < 0.9) {
+ basisU.crossVectors(axis, new THREE.Vector3(1, 0, 0)).normalize();
+ } else {
+ basisU.crossVectors(axis, new THREE.Vector3(0, 1, 0)).normalize();
+ }
+ basisV.crossVectors(axis, basisU).normalize();
+
+ // Project unique vertices to 2D
+ const seen = new Set();
+ const points2D: { u: number; v: number }[] = [];
+
+ for (const vert of cylinderVertices) {
+ const key = `${vert.x.toFixed(6)},${vert.y.toFixed(6)},${vert.z.toFixed(6)}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+
+ const rel = vert.clone().sub(hitCenter);
+ points2D.push({
+ u: rel.dot(basisU),
+ v: rel.dot(basisV),
+ });
+ }
+
+ if (points2D.length < 4) return null;
+
+ // Least-squares circle fit (Kasa method)
+ const result = circleFitKasa(points2D);
+ if (!result) return null;
+
+ const radiusModel = result.radius;
+ const diameterMM = radiusModel * 2 * 1000;
+
+ // Filter: reasonable hole sizes (2mm to 200mm diameter)
+ if (diameterMM < 2 || diameterMM > 200) return null;
+
+ // Reconstruct 3D center
+ const center3D = hitCenter.clone()
+ .add(basisU.clone().multiplyScalar(result.cx))
+ .add(basisV.clone().multiplyScalar(result.cy));
+
+ center3D.applyMatrix4(mesh.matrixWorld);
+
+ return { center: center3D, diameterMM };
+}
+
+/** Kasa circle fit: returns { cx, cy, radius } in the input coordinate system */
+function circleFitKasa(points: { u: number; v: number }[]): { cx: number; cy: number; radius: number } | null {
+ const n = points.length;
+ if (n < 3) return null;
+
+ let su = 0, sv = 0, suu = 0, svv = 0, suv = 0, suuu = 0, svvv = 0, suvv = 0, svuu = 0;
+
+ for (const p of points) {
+ su += p.u; sv += p.v;
+ suu += p.u * p.u; svv += p.v * p.v;
+ suv += p.u * p.v;
+ suuu += p.u * p.u * p.u; svvv += p.v * p.v * p.v;
+ suvv += p.u * p.v * p.v; svuu += p.v * p.u * p.u;
+ }
+
+ const A = n * suu - su * su;
+ const B = n * suv - su * sv;
+ const C = n * svv - sv * sv;
+ const D = 0.5 * (n * suuu + n * suvv - su * suu - su * svv);
+ const E = 0.5 * (n * svvv + n * svuu - sv * suu - sv * svv);
+
+ const denom = A * C - B * B;
+ if (Math.abs(denom) < 1e-12) return null;
+
+ const cx = (D * C - E * B) / denom;
+ const cy = (A * E - B * D) / denom;
+
+ let r2sum = 0;
+ for (const p of points) {
+ r2sum += (p.u - cx) ** 2 + (p.v - cy) ** 2;
+ }
+ const radius = Math.sqrt(r2sum / n);
+
+ // Check fit quality: standard deviation of radii should be small relative to radius
+ let variance = 0;
+ for (const p of points) {
+ const r = Math.sqrt((p.u - cx) ** 2 + (p.v - cy) ** 2);
+ variance += (r - radius) ** 2;
+ }
+ const stdDev = Math.sqrt(variance / n);
+ if (stdDev / radius > 0.15) return null; // Poor fit, not a circle
+
+ return { cx, cy, radius };
+}
+
+/**
+ * Find nearest edge segment from EdgesGeometry data.
+ * Returns distance in mm or null.
+ */
+export function findNearestEdgeSegment(
+ mesh: THREE.Mesh,
+ worldPoint: THREE.Vector3,
+ camera: THREE.Camera,
+ canvasSize: { width: number; height: number },
+ thresholdPx: number = 12
+): { midpoint: THREE.Vector3; lengthMM: number } | null {
+ // Look for edge line segments children
+ let edgeLines: THREE.LineSegments | null = null;
+ mesh.children.forEach(c => {
+ if (c.userData.__edgeLine && c instanceof THREE.LineSegments) {
+ edgeLines = c;
+ }
+ });
+
+ // If no edge lines, generate from EdgesGeometry
+ const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15);
+ const posAttr = geo.attributes.position;
+ if (!posAttr) return null;
+
+ // Project hit point to screen
+ const hitScreen = worldPoint.clone().project(camera);
+ const hitX = (hitScreen.x * 0.5 + 0.5) * canvasSize.width;
+ const hitY = (-hitScreen.y * 0.5 + 0.5) * canvasSize.height;
+
+ const matrix = edgeLines ? edgeLines.matrixWorld.clone().premultiply(mesh.matrixWorld) : mesh.matrixWorld;
+
+ let bestDist = Infinity;
+ let bestMid: THREE.Vector3 | null = null;
+ let bestLen = 0;
+
+ const segCount = posAttr.count / 2;
+ const a = new THREE.Vector3();
+ const b = new THREE.Vector3();
+ const pa = new THREE.Vector3();
+ const pb = new THREE.Vector3();
+
+ for (let i = 0; i < segCount; i++) {
+ a.fromBufferAttribute(posAttr, i * 2).applyMatrix4(matrix);
+ b.fromBufferAttribute(posAttr, i * 2 + 1).applyMatrix4(matrix);
+
+ // Project to screen
+ pa.copy(a).project(camera);
+ pb.copy(b).project(camera);
+ const ax = (pa.x * 0.5 + 0.5) * canvasSize.width;
+ const ay = (-pa.y * 0.5 + 0.5) * canvasSize.height;
+ const bx = (pb.x * 0.5 + 0.5) * canvasSize.width;
+ const by = (-pb.y * 0.5 + 0.5) * canvasSize.height;
+
+ // Distance from point to line segment in screen space
+ const dist = pointToSegmentDist(hitX, hitY, ax, ay, bx, by);
+
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestMid = a.clone().add(b).multiplyScalar(0.5);
+ bestLen = a.distanceTo(b) * 1000; // mm
+ }
+ }
+
+ if (!edgeLines) geo.dispose();
+
+ if (bestDist <= thresholdPx && bestMid && bestLen > 0.5) {
+ return { midpoint: bestMid, lengthMM: bestLen };
+ }
+ return null;
+}
+
+function pointToSegmentDist(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {
+ const dx = bx - ax;
+ const dy = by - ay;
+ const lenSq = dx * dx + dy * dy;
+ if (lenSq < 0.01) return Math.sqrt((px - ax) ** 2 + (py - ay) ** 2);
+
+ let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
+ t = Math.max(0, Math.min(1, t));
+ const closestX = ax + t * dx;
+ const closestY = ay + t * dy;
+ return Math.sqrt((px - closestX) ** 2 + (py - closestY) ** 2);
+}
diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts
index 2e91ba4..c6a3bf4 100644
--- a/src/stores/useModelStore.ts
+++ b/src/stores/useModelStore.ts
@@ -30,6 +30,12 @@ export interface Measurement {
distanceMM: number;
}
+export interface HoverInfo {
+ type: 'hole' | 'edge';
+ position: [number, number, number];
+ value: number; // mm
+}
+
export interface ChecklistItem {
id: string;
label: string;
@@ -99,6 +105,12 @@ interface ModelStore {
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;
@@ -192,6 +204,12 @@ export const useModelStore = create((set) => ({
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) => ({