Initial commit
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user