575 lines
15 KiB
TypeScript
575 lines
15 KiB
TypeScript
/**
|
|
* 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 (
|
|
<>
|
|
<Line
|
|
points={points}
|
|
color="#ff6b6b"
|
|
lineWidth={2}
|
|
dashed
|
|
dashSize={0.002}
|
|
gapSize={0.001}
|
|
/>
|
|
<Line
|
|
points={bottomPoints}
|
|
color="#ff6b6b"
|
|
lineWidth={2}
|
|
dashed
|
|
dashSize={0.002}
|
|
gapSize={0.001}
|
|
/>
|
|
{/* Center marker */}
|
|
<mesh position={[x, 0, 0.3]}>
|
|
<circleGeometry args={[0.001, 16]} />
|
|
<meshBasicMaterial color="#ff6b6b" />
|
|
</mesh>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<>
|
|
<Line
|
|
points={points}
|
|
color="#f59e0b"
|
|
lineWidth={2}
|
|
dashed
|
|
dashSize={0.002}
|
|
gapSize={0.001}
|
|
/>
|
|
{/* Center marker */}
|
|
<mesh position={[x, wallY, 0.3]}>
|
|
<circleGeometry args={[0.001, 16]} />
|
|
<meshBasicMaterial color="#f59e0b" />
|
|
</mesh>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 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 */}
|
|
<Line
|
|
points={points}
|
|
color="#22c55e"
|
|
lineWidth={isDrawing ? 3 : 2}
|
|
/>
|
|
|
|
{/* Path points markers */}
|
|
{path.map((point, i) => (
|
|
<mesh key={i} position={[point[0], point[1], 0.3]}>
|
|
<circleGeometry args={[radius, 16]} />
|
|
<meshBasicMaterial
|
|
color={i === 0 ? "#4ade80" : i === path.length - 1 ? "#22c55e" : "#86efac"}
|
|
transparent
|
|
opacity={0.7}
|
|
/>
|
|
</mesh>
|
|
))}
|
|
|
|
{/* Show drawing cursor if actively drawing */}
|
|
{isDrawing && path.length > 0 && (
|
|
<mesh position={[path[path.length - 1][0], path[path.length - 1][1], 0.4]}>
|
|
<ringGeometry args={[radius * 1.5, radius * 2, 16]} />
|
|
<meshBasicMaterial color="#4ade80" transparent opacity={0.5} />
|
|
</mesh>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<group position={[position[0], position[1], 0.5]}>
|
|
{/* Horizontal line */}
|
|
<Line
|
|
points={[
|
|
new THREE.Vector3(-size, 0, 0),
|
|
new THREE.Vector3(size, 0, 0),
|
|
]}
|
|
color={color}
|
|
lineWidth={1}
|
|
/>
|
|
{/* Vertical line */}
|
|
<Line
|
|
points={[
|
|
new THREE.Vector3(0, -size, 0),
|
|
new THREE.Vector3(0, size, 0),
|
|
]}
|
|
color={color}
|
|
lineWidth={1}
|
|
/>
|
|
</group>
|
|
);
|
|
}
|
|
|
|
/** Interactive editor scene content */
|
|
function EditorScene({
|
|
editorState,
|
|
onStateChange,
|
|
vesselLength,
|
|
vesselRadius,
|
|
onPointSelected,
|
|
}: GeometryEditorProps) {
|
|
const { camera, gl } = useThree();
|
|
const [cursorPosition, setCursorPosition] = useState<Point2D | null>(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 && (
|
|
<StenosisPreview
|
|
position={editorState.stenosis.position}
|
|
diameterRatio={editorState.stenosis.diameterRatio}
|
|
length={editorState.stenosis.length}
|
|
vesselRadius={vesselRadius}
|
|
/>
|
|
)}
|
|
|
|
{/* Aneurysm preview */}
|
|
{editorState.previewVisible &&
|
|
editorState.currentTool === "aneurysm" &&
|
|
editorState.aneurysm.position && (
|
|
<AneurysmPreview
|
|
position={editorState.aneurysm.position}
|
|
diameterRatio={editorState.aneurysm.diameterRatio}
|
|
sacRadius={editorState.aneurysm.sacRadius}
|
|
vesselRadius={vesselRadius}
|
|
/>
|
|
)}
|
|
|
|
{/* Stent path preview */}
|
|
{editorState.previewVisible && editorState.stent.path.length > 0 && (
|
|
<StentPreview
|
|
path={editorState.stent.path}
|
|
radius={editorState.stent.radius}
|
|
isDrawing={editorState.stent.isDrawing}
|
|
/>
|
|
)}
|
|
|
|
{/* Cursor crosshair */}
|
|
<Crosshair position={cursorPosition} tool={editorState.currentTool} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div
|
|
className="geometry-editor-overlay"
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
<Canvas
|
|
orthographic
|
|
style={{
|
|
background: "transparent",
|
|
pointerEvents: "auto",
|
|
}}
|
|
>
|
|
<OrthographicCamera
|
|
makeDefault
|
|
position={[vesselLength / 2, 0, 10]}
|
|
zoom={2000}
|
|
near={0.1}
|
|
far={1000}
|
|
/>
|
|
<EditorScene {...props} />
|
|
</Canvas>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default GeometryEditor;
|