Files
rustytorch/demos/ui/src/components/GeometryTools.tsx
T
2026-03-04 00:08:42 +00:00

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">&#9758;</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">&#9995;</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">&#10095;&#10094;</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">&#11044;</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">&#9644;</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;