/**
* 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;