/** * Interactive geometry editor overlay for the vessel canvas * Handles mouse events for drawing stents, placing stenosis/aneurysm */ import { useRef, useCallback, useEffect, useState, useMemo } from "react"; import { Canvas, useThree } from "@react-three/fiber"; import { OrthographicCamera, Line } from "@react-three/drei"; import * as THREE from "three"; import type { Point2D } from "../lib/types"; import type { GeometryEditorState, GeometryTool } from "./GeometryTools"; /** Props for the geometry editor */ interface GeometryEditorProps { editorState: GeometryEditorState; onStateChange: (state: GeometryEditorState) => void; vesselLength: number; vesselRadius: number; onPointSelected?: (point: Point2D) => void; } /** Convert screen coordinates to world coordinates */ function screenToWorld( clientX: number, clientY: number, canvas: HTMLCanvasElement, camera: THREE.OrthographicCamera ): Point2D { const rect = canvas.getBoundingClientRect(); const x = ((clientX - rect.left) / rect.width) * 2 - 1; const y = -((clientY - rect.top) / rect.height) * 2 + 1; const vector = new THREE.Vector3(x, y, 0); vector.unproject(camera); return [vector.x, vector.y]; } /** Clamp point to vessel bounds */ function clampToVessel( point: Point2D, vesselLength: number, vesselRadius: number ): Point2D { return [ Math.max(0, Math.min(vesselLength, point[0])), Math.max(-vesselRadius, Math.min(vesselRadius, point[1])), ]; } /** Preview overlay for stenosis placement */ function StenosisPreview({ position, diameterRatio, length, vesselRadius, }: { position: Point2D; diameterRatio: number; length: number; vesselRadius: number; }) { const halfLength = length / 2; const narrowRadius = vesselRadius * diameterRatio; const x = position[0]; // Create stenosis shape outline const points = useMemo(() => { const pts: THREE.Vector3[] = []; const segments = 20; // Top wall narrowing (smooth transition) for (let i = 0; i <= segments; i++) { const t = i / segments; const localX = x - halfLength + t * length; const narrowing = Math.sin(t * Math.PI) * (vesselRadius - narrowRadius); pts.push(new THREE.Vector3(localX, vesselRadius - narrowing, 0.2)); } return pts; }, [x, halfLength, length, vesselRadius, narrowRadius]); const bottomPoints = useMemo(() => { const pts: THREE.Vector3[] = []; const segments = 20; // Bottom wall narrowing (symmetric) for (let i = 0; i <= segments; i++) { const t = i / segments; const localX = x - halfLength + t * length; const narrowing = Math.sin(t * Math.PI) * (vesselRadius - narrowRadius); pts.push(new THREE.Vector3(localX, -vesselRadius + narrowing, 0.2)); } return pts; }, [x, halfLength, length, vesselRadius, narrowRadius]); return ( <> {/* Center marker */} ); } /** Preview overlay for aneurysm placement */ function AneurysmPreview({ position, diameterRatio, sacRadius, vesselRadius, }: { position: Point2D; diameterRatio: number; sacRadius: number; vesselRadius: number; }) { const x = position[0]; const y = position[1]; // Determine if on top or bottom wall const isTop = y >= 0; const wallY = isTop ? vesselRadius : -vesselRadius; const bulgeFactor = isTop ? 1 : -1; const bulgeRadius = sacRadius * diameterRatio; // Create aneurysm bulge outline const points = useMemo(() => { const pts: THREE.Vector3[] = []; const segments = 32; // Create a semicircle bulge at the wall for (let i = 0; i <= segments; i++) { const angle = isTop ? -Math.PI + (i / segments) * Math.PI : (i / segments) * Math.PI; const px = x + Math.cos(angle) * bulgeRadius; const py = wallY + Math.sin(angle) * bulgeRadius * bulgeFactor; pts.push(new THREE.Vector3(px, py, 0.2)); } return pts; }, [x, wallY, bulgeRadius, bulgeFactor, isTop]); return ( <> {/* Center marker */} ); } /** Preview overlay for stent path */ function StentPreview({ path, radius, isDrawing, }: { path: Point2D[]; radius: number; isDrawing: boolean; }) { // useMemo must be called unconditionally (before any returns) const points = useMemo( () => path.map((p) => new THREE.Vector3(p[0], p[1], 0.2)), [path] ); if (path.length === 0) return null; return ( <> {/* Main path line */} {/* Path points markers */} {path.map((point, i) => ( ))} {/* Show drawing cursor if actively drawing */} {isDrawing && path.length > 0 && ( )} ); } /** Crosshair cursor for precise placement */ function Crosshair({ position, tool, }: { position: Point2D | null; tool: GeometryTool; }) { if (!position || tool === "select" || tool === "pan") return null; const color = tool === "stenosis" ? "#ff6b6b" : tool === "aneurysm" ? "#f59e0b" : "#22c55e"; const size = 0.003; return ( {/* Horizontal line */} {/* Vertical line */} ); } /** Interactive editor scene content */ function EditorScene({ editorState, onStateChange, vesselLength, vesselRadius, onPointSelected, }: GeometryEditorProps) { const { camera, gl } = useThree(); const [cursorPosition, setCursorPosition] = useState(null); const isMouseDown = useRef(false); const isDragging = useRef(false); const lastMousePos = useRef<{ x: number; y: number } | null>(null); // Handle mouse move const handleMouseMove = useCallback( (event: MouseEvent) => { const canvas = gl.domElement; const point = screenToWorld( event.clientX, event.clientY, canvas, camera as THREE.OrthographicCamera ); const clamped = clampToVessel(point, vesselLength, vesselRadius); setCursorPosition(clamped); // Handle pan dragging if ( editorState.currentTool === "pan" && isMouseDown.current && lastMousePos.current ) { isDragging.current = true; // Pan logic would be implemented here with camera controls // For now, we track the drag state } // Handle stent drawing if ( editorState.currentTool === "stent" && editorState.stent.isDrawing && isMouseDown.current ) { const lastPoint = editorState.stent.path[editorState.stent.path.length - 1]; const distance = Math.sqrt( (clamped[0] - lastPoint[0]) ** 2 + (clamped[1] - lastPoint[1]) ** 2 ); // Only add points if moved enough distance if (distance > 0.002) { onStateChange({ ...editorState, stent: { ...editorState.stent, path: [...editorState.stent.path, clamped], }, }); } } // Update hover position for preview if (editorState.currentTool === "stenosis" && !isMouseDown.current) { // For stenosis, snap to vessel centerline (y=0) const stenosisPoint: Point2D = [clamped[0], 0]; onStateChange({ ...editorState, stenosis: { ...editorState.stenosis, position: stenosisPoint, }, }); } if (editorState.currentTool === "aneurysm" && !isMouseDown.current) { onStateChange({ ...editorState, aneurysm: { ...editorState.aneurysm, position: clamped, }, }); } lastMousePos.current = { x: event.clientX, y: event.clientY }; }, [camera, gl, vesselLength, vesselRadius, editorState, onStateChange] ); // Handle mouse down const handleMouseDown = useCallback( (event: MouseEvent) => { if (event.button !== 0) return; // Only left click isMouseDown.current = true; isDragging.current = false; const canvas = gl.domElement; const point = screenToWorld( event.clientX, event.clientY, canvas, camera as THREE.OrthographicCamera ); const clamped = clampToVessel(point, vesselLength, vesselRadius); // Start stent drawing if (editorState.currentTool === "stent" && !editorState.stent.isDrawing) { onStateChange({ ...editorState, stent: { ...editorState.stent, isDrawing: true, path: [clamped], }, }); } // Place stenosis on click if (editorState.currentTool === "stenosis") { const stenosisPoint: Point2D = [clamped[0], 0]; onStateChange({ ...editorState, stenosis: { ...editorState.stenosis, position: stenosisPoint, }, }); } // Place aneurysm on click if (editorState.currentTool === "aneurysm") { onStateChange({ ...editorState, aneurysm: { ...editorState.aneurysm, position: clamped, }, }); } // Select tool - report point if (editorState.currentTool === "select" && onPointSelected) { onPointSelected(clamped); } lastMousePos.current = { x: event.clientX, y: event.clientY }; }, [camera, gl, vesselLength, vesselRadius, editorState, onStateChange, onPointSelected] ); // Handle mouse up const handleMouseUp = useCallback(() => { isMouseDown.current = false; // End stent drawing if (editorState.currentTool === "stent" && editorState.stent.isDrawing) { onStateChange({ ...editorState, stent: { ...editorState.stent, isDrawing: false, }, }); } isDragging.current = false; lastMousePos.current = null; }, [editorState, onStateChange]); // Handle mouse leave const handleMouseLeave = useCallback(() => { setCursorPosition(null); isMouseDown.current = false; // End stent drawing if mouse leaves canvas if (editorState.currentTool === "stent" && editorState.stent.isDrawing) { onStateChange({ ...editorState, stent: { ...editorState.stent, isDrawing: false, }, }); } }, [editorState, onStateChange]); // Set up event listeners useEffect(() => { const canvas = gl.domElement; canvas.addEventListener("mousemove", handleMouseMove); canvas.addEventListener("mousedown", handleMouseDown); canvas.addEventListener("mouseup", handleMouseUp); canvas.addEventListener("mouseleave", handleMouseLeave); return () => { canvas.removeEventListener("mousemove", handleMouseMove); canvas.removeEventListener("mousedown", handleMouseDown); canvas.removeEventListener("mouseup", handleMouseUp); canvas.removeEventListener("mouseleave", handleMouseLeave); }; }, [gl, handleMouseMove, handleMouseDown, handleMouseUp, handleMouseLeave]); // Update cursor style based on tool useEffect(() => { const canvas = gl.domElement; switch (editorState.currentTool) { case "select": canvas.style.cursor = "crosshair"; break; case "pan": canvas.style.cursor = isDragging.current ? "grabbing" : "grab"; break; case "stenosis": case "aneurysm": case "stent": canvas.style.cursor = "none"; // We show custom crosshair break; default: canvas.style.cursor = "default"; } }, [gl, editorState.currentTool]); return ( <> {/* Stenosis preview */} {editorState.previewVisible && editorState.currentTool === "stenosis" && editorState.stenosis.position && ( )} {/* Aneurysm preview */} {editorState.previewVisible && editorState.currentTool === "aneurysm" && editorState.aneurysm.position && ( )} {/* Stent path preview */} {editorState.previewVisible && editorState.stent.path.length > 0 && ( )} {/* Cursor crosshair */} ); } /** * Geometry editor canvas overlay * This renders on top of the main vessel canvas to handle interactions */ export function GeometryEditor(props: GeometryEditorProps) { const { vesselLength } = props; return (
); } export default GeometryEditor;