738 lines
20 KiB
TypeScript
738 lines
20 KiB
TypeScript
/**
|
|
* Geometry editing toolbar for interactive vessel modification
|
|
* Provides tools for drawing stents, adding stenosis/aneurysm
|
|
*/
|
|
|
|
import { useState, useCallback } from "react";
|
|
import type { Point2D, StenosisParams, AneurysmParams, StentParams } from "../lib/types";
|
|
|
|
/** Available geometry editing tools */
|
|
export type GeometryTool = "select" | "stent" | "stenosis" | "aneurysm" | "pan";
|
|
|
|
/** Tool state for stenosis placement */
|
|
export interface StenosisToolState {
|
|
active: boolean;
|
|
position: Point2D | null;
|
|
diameterRatio: number;
|
|
length: number;
|
|
}
|
|
|
|
/** Tool state for aneurysm placement */
|
|
export interface AneurysmToolState {
|
|
active: boolean;
|
|
position: Point2D | null;
|
|
diameterRatio: number;
|
|
sacRadius: number;
|
|
}
|
|
|
|
/** Tool state for stent drawing */
|
|
export interface StentToolState {
|
|
active: boolean;
|
|
path: Point2D[];
|
|
radius: number;
|
|
porosity: number;
|
|
isDrawing: boolean;
|
|
}
|
|
|
|
/** Combined geometry editing state */
|
|
export interface GeometryEditorState {
|
|
currentTool: GeometryTool;
|
|
stenosis: StenosisToolState;
|
|
aneurysm: AneurysmToolState;
|
|
stent: StentToolState;
|
|
previewVisible: boolean;
|
|
}
|
|
|
|
/** Initial state for geometry editor */
|
|
export const initialEditorState: GeometryEditorState = {
|
|
currentTool: "select",
|
|
stenosis: {
|
|
active: false,
|
|
position: null,
|
|
diameterRatio: 0.5,
|
|
length: 0.01,
|
|
},
|
|
aneurysm: {
|
|
active: false,
|
|
position: null,
|
|
diameterRatio: 1.5,
|
|
sacRadius: 0.003,
|
|
},
|
|
stent: {
|
|
active: false,
|
|
path: [],
|
|
radius: 0.0005,
|
|
porosity: 0.8,
|
|
isDrawing: false,
|
|
},
|
|
previewVisible: true,
|
|
};
|
|
|
|
interface GeometryToolsProps {
|
|
editorState: GeometryEditorState;
|
|
onStateChange: (state: GeometryEditorState) => void;
|
|
onApplyStenosis: (params: StenosisParams) => void;
|
|
onApplyAneurysm: (params: AneurysmParams) => void;
|
|
onApplyStent: (params: StentParams) => void;
|
|
onResetGeometry: () => void;
|
|
isDisabled: boolean;
|
|
vesselLength: number;
|
|
vesselRadius: number;
|
|
}
|
|
|
|
/**
|
|
* Toolbar component for geometry editing tools
|
|
*/
|
|
export function GeometryTools({
|
|
editorState,
|
|
onStateChange,
|
|
onApplyStenosis,
|
|
onApplyAneurysm,
|
|
onApplyStent,
|
|
onResetGeometry,
|
|
isDisabled,
|
|
vesselLength,
|
|
vesselRadius,
|
|
}: GeometryToolsProps) {
|
|
const [showHelp, setShowHelp] = useState(false);
|
|
|
|
const selectTool = useCallback(
|
|
(tool: GeometryTool) => {
|
|
onStateChange({
|
|
...editorState,
|
|
currentTool: tool,
|
|
stenosis: { ...editorState.stenosis, active: tool === "stenosis" },
|
|
aneurysm: { ...editorState.aneurysm, active: tool === "aneurysm" },
|
|
stent: {
|
|
...editorState.stent,
|
|
active: tool === "stent",
|
|
isDrawing: false,
|
|
path: tool === "stent" ? [] : editorState.stent.path,
|
|
},
|
|
});
|
|
},
|
|
[editorState, onStateChange]
|
|
);
|
|
|
|
const handleApplyStenosis = useCallback(() => {
|
|
if (editorState.stenosis.position) {
|
|
onApplyStenosis({
|
|
diameter_ratio: editorState.stenosis.diameterRatio,
|
|
length: editorState.stenosis.length,
|
|
center_x: editorState.stenosis.position[0],
|
|
});
|
|
// Reset stenosis tool state after application
|
|
onStateChange({
|
|
...editorState,
|
|
stenosis: { ...editorState.stenosis, position: null },
|
|
});
|
|
}
|
|
}, [editorState, onApplyStenosis, onStateChange]);
|
|
|
|
const handleApplyAneurysm = useCallback(() => {
|
|
if (editorState.aneurysm.position) {
|
|
onApplyAneurysm({
|
|
diameter_ratio: editorState.aneurysm.diameterRatio,
|
|
sac_radius: editorState.aneurysm.sacRadius,
|
|
center_x: editorState.aneurysm.position[0],
|
|
center_y: editorState.aneurysm.position[1],
|
|
});
|
|
// Reset aneurysm tool state after application
|
|
onStateChange({
|
|
...editorState,
|
|
aneurysm: { ...editorState.aneurysm, position: null },
|
|
});
|
|
}
|
|
}, [editorState, onApplyAneurysm, onStateChange]);
|
|
|
|
const handleApplyStent = useCallback(() => {
|
|
if (editorState.stent.path.length >= 2) {
|
|
onApplyStent({
|
|
path: editorState.stent.path,
|
|
radius: editorState.stent.radius,
|
|
porosity: editorState.stent.porosity,
|
|
});
|
|
// Reset stent tool state after application
|
|
onStateChange({
|
|
...editorState,
|
|
stent: { ...editorState.stent, path: [], isDrawing: false },
|
|
});
|
|
}
|
|
}, [editorState, onApplyStent, onStateChange]);
|
|
|
|
const handleClearStentPath = useCallback(() => {
|
|
onStateChange({
|
|
...editorState,
|
|
stent: { ...editorState.stent, path: [], isDrawing: false },
|
|
});
|
|
}, [editorState, onStateChange]);
|
|
|
|
const updateStenosisParams = useCallback(
|
|
(updates: Partial<StenosisToolState>) => {
|
|
onStateChange({
|
|
...editorState,
|
|
stenosis: { ...editorState.stenosis, ...updates },
|
|
});
|
|
},
|
|
[editorState, onStateChange]
|
|
);
|
|
|
|
const updateAneurysmParams = useCallback(
|
|
(updates: Partial<AneurysmToolState>) => {
|
|
onStateChange({
|
|
...editorState,
|
|
aneurysm: { ...editorState.aneurysm, ...updates },
|
|
});
|
|
},
|
|
[editorState, onStateChange]
|
|
);
|
|
|
|
const updateStentParams = useCallback(
|
|
(updates: Partial<StentToolState>) => {
|
|
onStateChange({
|
|
...editorState,
|
|
stent: { ...editorState.stent, ...updates },
|
|
});
|
|
},
|
|
[editorState, onStateChange]
|
|
);
|
|
|
|
const togglePreview = useCallback(() => {
|
|
onStateChange({
|
|
...editorState,
|
|
previewVisible: !editorState.previewVisible,
|
|
});
|
|
}, [editorState, onStateChange]);
|
|
|
|
return (
|
|
<div className="geometry-tools">
|
|
<h3>Geometry Tools</h3>
|
|
|
|
{/* Tool selection buttons */}
|
|
<div className="tool-buttons">
|
|
<button
|
|
className={`tool-btn ${editorState.currentTool === "select" ? "active" : ""}`}
|
|
onClick={() => selectTool("select")}
|
|
disabled={isDisabled}
|
|
title="Select tool - Click to inspect points"
|
|
aria-label="Select tool"
|
|
>
|
|
<span className="tool-icon">☞</span>
|
|
<span className="tool-label">Select</span>
|
|
</button>
|
|
|
|
<button
|
|
className={`tool-btn ${editorState.currentTool === "pan" ? "active" : ""}`}
|
|
onClick={() => selectTool("pan")}
|
|
disabled={isDisabled}
|
|
title="Pan tool - Drag to move view"
|
|
aria-label="Pan tool"
|
|
>
|
|
<span className="tool-icon">✋</span>
|
|
<span className="tool-label">Pan</span>
|
|
</button>
|
|
|
|
<button
|
|
className={`tool-btn ${editorState.currentTool === "stenosis" ? "active" : ""}`}
|
|
onClick={() => selectTool("stenosis")}
|
|
disabled={isDisabled}
|
|
title="Stenosis tool - Click to place narrowing"
|
|
aria-label="Stenosis tool"
|
|
>
|
|
<span className="tool-icon">❯❮</span>
|
|
<span className="tool-label">Stenosis</span>
|
|
</button>
|
|
|
|
<button
|
|
className={`tool-btn ${editorState.currentTool === "aneurysm" ? "active" : ""}`}
|
|
onClick={() => selectTool("aneurysm")}
|
|
disabled={isDisabled}
|
|
title="Aneurysm tool - Click to place bulge"
|
|
aria-label="Aneurysm tool"
|
|
>
|
|
<span className="tool-icon">⬤</span>
|
|
<span className="tool-label">Aneurysm</span>
|
|
</button>
|
|
|
|
<button
|
|
className={`tool-btn ${editorState.currentTool === "stent" ? "active" : ""}`}
|
|
onClick={() => selectTool("stent")}
|
|
disabled={isDisabled}
|
|
title="Stent tool - Draw path for virtual stent"
|
|
aria-label="Stent tool"
|
|
>
|
|
<span className="tool-icon">▬</span>
|
|
<span className="tool-label">Stent</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tool-specific controls */}
|
|
{editorState.currentTool === "stenosis" && (
|
|
<div className="tool-params">
|
|
<h4>Stenosis Parameters</h4>
|
|
|
|
<div className="param-row">
|
|
<label>Diameter Ratio:</label>
|
|
<input
|
|
type="range"
|
|
value={editorState.stenosis.diameterRatio}
|
|
onChange={(e) =>
|
|
updateStenosisParams({ diameterRatio: parseFloat(e.target.value) })
|
|
}
|
|
min={0.1}
|
|
max={0.9}
|
|
step={0.05}
|
|
disabled={isDisabled}
|
|
/>
|
|
<span>{(editorState.stenosis.diameterRatio * 100).toFixed(0)}%</span>
|
|
</div>
|
|
|
|
<div className="param-row">
|
|
<label>Length (m):</label>
|
|
<input
|
|
type="range"
|
|
value={editorState.stenosis.length}
|
|
onChange={(e) =>
|
|
updateStenosisParams({ length: parseFloat(e.target.value) })
|
|
}
|
|
min={0.002}
|
|
max={vesselLength * 0.5}
|
|
step={0.001}
|
|
disabled={isDisabled}
|
|
/>
|
|
<span>{(editorState.stenosis.length * 1000).toFixed(1)} mm</span>
|
|
</div>
|
|
|
|
{editorState.stenosis.position && (
|
|
<div className="position-info">
|
|
Position: ({(editorState.stenosis.position[0] * 1000).toFixed(1)} mm, 0)
|
|
</div>
|
|
)}
|
|
|
|
<div className="tool-actions">
|
|
<button
|
|
onClick={handleApplyStenosis}
|
|
disabled={isDisabled || !editorState.stenosis.position}
|
|
className="apply-btn"
|
|
>
|
|
Apply Stenosis
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{editorState.currentTool === "aneurysm" && (
|
|
<div className="tool-params">
|
|
<h4>Aneurysm Parameters</h4>
|
|
|
|
<div className="param-row">
|
|
<label>Diameter Ratio:</label>
|
|
<input
|
|
type="range"
|
|
value={editorState.aneurysm.diameterRatio}
|
|
onChange={(e) =>
|
|
updateAneurysmParams({ diameterRatio: parseFloat(e.target.value) })
|
|
}
|
|
min={1.1}
|
|
max={3.0}
|
|
step={0.1}
|
|
disabled={isDisabled}
|
|
/>
|
|
<span>{editorState.aneurysm.diameterRatio.toFixed(1)}x</span>
|
|
</div>
|
|
|
|
<div className="param-row">
|
|
<label>Sac Radius:</label>
|
|
<input
|
|
type="range"
|
|
value={editorState.aneurysm.sacRadius}
|
|
onChange={(e) =>
|
|
updateAneurysmParams({ sacRadius: parseFloat(e.target.value) })
|
|
}
|
|
min={vesselRadius * 0.2}
|
|
max={vesselRadius * 2}
|
|
step={0.0005}
|
|
disabled={isDisabled}
|
|
/>
|
|
<span>{(editorState.aneurysm.sacRadius * 1000).toFixed(2)} mm</span>
|
|
</div>
|
|
|
|
{editorState.aneurysm.position && (
|
|
<div className="position-info">
|
|
Position: ({(editorState.aneurysm.position[0] * 1000).toFixed(1)} mm,{" "}
|
|
{(editorState.aneurysm.position[1] * 1000).toFixed(1)} mm)
|
|
</div>
|
|
)}
|
|
|
|
<div className="tool-actions">
|
|
<button
|
|
onClick={handleApplyAneurysm}
|
|
disabled={isDisabled || !editorState.aneurysm.position}
|
|
className="apply-btn"
|
|
>
|
|
Apply Aneurysm
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{editorState.currentTool === "stent" && (
|
|
<div className="tool-params">
|
|
<h4>Stent Parameters</h4>
|
|
|
|
<div className="param-row">
|
|
<label>Radius:</label>
|
|
<input
|
|
type="range"
|
|
value={editorState.stent.radius}
|
|
onChange={(e) =>
|
|
updateStentParams({ radius: parseFloat(e.target.value) })
|
|
}
|
|
min={0.0002}
|
|
max={vesselRadius * 0.5}
|
|
step={0.0001}
|
|
disabled={isDisabled}
|
|
/>
|
|
<span>{(editorState.stent.radius * 1000).toFixed(2)} mm</span>
|
|
</div>
|
|
|
|
<div className="param-row">
|
|
<label>Porosity:</label>
|
|
<input
|
|
type="range"
|
|
value={editorState.stent.porosity}
|
|
onChange={(e) =>
|
|
updateStentParams({ porosity: parseFloat(e.target.value) })
|
|
}
|
|
min={0.1}
|
|
max={0.95}
|
|
step={0.05}
|
|
disabled={isDisabled}
|
|
/>
|
|
<span>{(editorState.stent.porosity * 100).toFixed(0)}%</span>
|
|
</div>
|
|
|
|
<div className="path-info">
|
|
Path points: {editorState.stent.path.length}
|
|
{editorState.stent.isDrawing && (
|
|
<span className="drawing-indicator"> (Drawing...)</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="tool-actions">
|
|
<button
|
|
onClick={handleApplyStent}
|
|
disabled={isDisabled || editorState.stent.path.length < 2}
|
|
className="apply-btn"
|
|
>
|
|
Apply Stent
|
|
</button>
|
|
<button
|
|
onClick={handleClearStentPath}
|
|
disabled={isDisabled || editorState.stent.path.length === 0}
|
|
className="clear-btn"
|
|
>
|
|
Clear Path
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Common actions */}
|
|
<div className="common-actions">
|
|
<button
|
|
onClick={onResetGeometry}
|
|
disabled={isDisabled}
|
|
className="reset-btn"
|
|
>
|
|
Reset Geometry
|
|
</button>
|
|
|
|
<label className="preview-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={editorState.previewVisible}
|
|
onChange={togglePreview}
|
|
disabled={isDisabled}
|
|
/>
|
|
Show Preview
|
|
</label>
|
|
</div>
|
|
|
|
{/* Help button and panel */}
|
|
<button
|
|
className="help-btn"
|
|
onClick={() => setShowHelp(!showHelp)}
|
|
aria-label="Toggle help"
|
|
>
|
|
{showHelp ? "Hide Help" : "Show Help"}
|
|
</button>
|
|
|
|
{showHelp && (
|
|
<div className="help-panel">
|
|
<h4>How to Use</h4>
|
|
<ul>
|
|
<li><strong>Select:</strong> Click on the vessel to inspect field values</li>
|
|
<li><strong>Pan:</strong> Drag to move the view</li>
|
|
<li><strong>Stenosis:</strong> Click on vessel axis to place narrowing</li>
|
|
<li><strong>Aneurysm:</strong> Click near vessel wall to place bulge</li>
|
|
<li><strong>Stent:</strong> Click to start, drag to draw path, click to end</li>
|
|
</ul>
|
|
<p>After placing a modification, click "Apply" to update the simulation.</p>
|
|
</div>
|
|
)}
|
|
|
|
<style>{`
|
|
.geometry-tools {
|
|
background: #16213e;
|
|
color: #e4e4e4;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
width: 260px;
|
|
}
|
|
|
|
.geometry-tools h3 {
|
|
margin: 0 0 12px 0;
|
|
font-size: 14px;
|
|
color: #3282b8;
|
|
border-bottom: 1px solid #3282b8;
|
|
padding-bottom: 6px;
|
|
}
|
|
|
|
.geometry-tools h4 {
|
|
margin: 8px 0;
|
|
font-size: 12px;
|
|
color: #bbe1fa;
|
|
}
|
|
|
|
.tool-buttons {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, 1fr);
|
|
gap: 4px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.tool-btn {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 6px 2px;
|
|
border: 1px solid #3282b8;
|
|
border-radius: 4px;
|
|
background: #1a1a2e;
|
|
color: #e4e4e4;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.tool-btn:hover:not(:disabled) {
|
|
background: #0f4c75;
|
|
}
|
|
|
|
.tool-btn.active {
|
|
background: #0f4c75;
|
|
border-color: #bbe1fa;
|
|
color: #bbe1fa;
|
|
}
|
|
|
|
.tool-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.tool-icon {
|
|
font-size: 14px;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.tool-label {
|
|
font-size: 9px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.tool-params {
|
|
background: #1a1a2e;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.param-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.param-row label {
|
|
min-width: 80px;
|
|
}
|
|
|
|
.param-row input[type="range"] {
|
|
flex: 1;
|
|
height: 4px;
|
|
}
|
|
|
|
.param-row span {
|
|
min-width: 50px;
|
|
text-align: right;
|
|
font-family: monospace;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.position-info,
|
|
.path-info {
|
|
font-size: 10px;
|
|
color: #888;
|
|
margin: 8px 0;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.drawing-indicator {
|
|
color: #4ade80;
|
|
animation: pulse 1s infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
.tool-actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.apply-btn {
|
|
flex: 2;
|
|
padding: 6px 8px;
|
|
background: #22c55e;
|
|
border: none;
|
|
border-radius: 4px;
|
|
color: white;
|
|
cursor: pointer;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.apply-btn:hover:not(:disabled) {
|
|
background: #16a34a;
|
|
}
|
|
|
|
.apply-btn:disabled {
|
|
background: #444;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.clear-btn {
|
|
flex: 1;
|
|
padding: 6px 8px;
|
|
background: #ef4444;
|
|
border: none;
|
|
border-radius: 4px;
|
|
color: white;
|
|
cursor: pointer;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.clear-btn:hover:not(:disabled) {
|
|
background: #dc2626;
|
|
}
|
|
|
|
.clear-btn:disabled {
|
|
background: #444;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.common-actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
padding-top: 12px;
|
|
border-top: 1px solid #333;
|
|
}
|
|
|
|
.reset-btn {
|
|
width: 100%;
|
|
padding: 8px;
|
|
background: #dc2626;
|
|
border: none;
|
|
border-radius: 4px;
|
|
color: white;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.reset-btn:hover:not(:disabled) {
|
|
background: #b91c1c;
|
|
}
|
|
|
|
.reset-btn:disabled {
|
|
background: #444;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.preview-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.preview-toggle input {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.help-btn {
|
|
width: 100%;
|
|
padding: 6px;
|
|
margin-top: 8px;
|
|
background: transparent;
|
|
border: 1px solid #444;
|
|
border-radius: 4px;
|
|
color: #888;
|
|
cursor: pointer;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.help-btn:hover {
|
|
border-color: #666;
|
|
color: #aaa;
|
|
}
|
|
|
|
.help-panel {
|
|
margin-top: 8px;
|
|
padding: 8px;
|
|
background: #1a1a2e;
|
|
border-radius: 4px;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.help-panel h4 {
|
|
margin: 0 0 6px 0;
|
|
color: #bbe1fa;
|
|
}
|
|
|
|
.help-panel ul {
|
|
margin: 0;
|
|
padding-left: 16px;
|
|
}
|
|
|
|
.help-panel li {
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.help-panel p {
|
|
margin: 8px 0 0 0;
|
|
color: #888;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default GeometryTools;
|