2026-05-08-02-36-12 实现STL模型切分mask
This commit is contained in:
@@ -137,6 +137,14 @@ type LibraryViewerPreview = {
|
||||
window: string;
|
||||
windowLabel: string;
|
||||
patientId: string;
|
||||
modelId?: string;
|
||||
maskPixels?: number;
|
||||
};
|
||||
|
||||
type StlModel = {
|
||||
modelId: string;
|
||||
name: string;
|
||||
triangleCount: number;
|
||||
};
|
||||
|
||||
type StoredDeformationJob = {
|
||||
@@ -386,8 +394,18 @@ export default function App() {
|
||||
const [viewerPreview, setViewerPreview] = useState<LibraryViewerPreview | null>(null);
|
||||
const [isViewerLoading, setIsViewerLoading] = useState(false);
|
||||
const [viewerError, setViewerError] = useState('');
|
||||
const [stlModel, setStlModel] = useState<StlModel | null>(null);
|
||||
const [isUploadingStl, setIsUploadingStl] = useState(false);
|
||||
const [isModelSlicingEnabled, setIsModelSlicingEnabled] = useState(false);
|
||||
const [modelClipStart, setModelClipStart] = useState(0);
|
||||
const [modelClipEnd, setModelClipEnd] = useState(0);
|
||||
const [modelStartPreview, setModelStartPreview] = useState<LibraryViewerPreview | null>(null);
|
||||
const [modelEndPreview, setModelEndPreview] = useState<LibraryViewerPreview | null>(null);
|
||||
const [isModelMaskLoading, setIsModelMaskLoading] = useState(false);
|
||||
const [modelMaskError, setModelMaskError] = useState('');
|
||||
const folderUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const stlUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// --- Simulation State (Workspace) ---
|
||||
const [cervicalRotation, setCervicalRotation] = useState(14.5);
|
||||
@@ -427,6 +445,9 @@ export default function App() {
|
||||
const selectedVideoSource = VIDEO_SOURCE_OPTIONS.find(option => option.key === videoSource) || VIDEO_SOURCE_OPTIONS[0];
|
||||
const videoSourceInputDir = selectedInputDir;
|
||||
const isVideoSourceReady = Boolean(videoSourceInputDir);
|
||||
const viewerFrameCount = Math.max(1, viewerPreview?.count || modelStartPreview?.count || modelEndPreview?.count || libraryViewerItem?.fileCount || 1);
|
||||
const clampedModelStart = Math.max(0, Math.min(viewerFrameCount - 1, modelClipStart));
|
||||
const clampedModelEnd = Math.max(0, Math.min(viewerFrameCount - 1, modelClipEnd));
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeUserMenu) return;
|
||||
@@ -929,6 +950,12 @@ export default function App() {
|
||||
setDebouncedViewerSliceIndex('middle');
|
||||
setViewerPreview(null);
|
||||
setViewerError('');
|
||||
setIsModelSlicingEnabled(false);
|
||||
setModelClipStart(0);
|
||||
setModelClipEnd(Math.max(0, (item.fileCount || 1) - 1));
|
||||
setModelStartPreview(null);
|
||||
setModelEndPreview(null);
|
||||
setModelMaskError('');
|
||||
};
|
||||
|
||||
const closeLibraryViewer = () => {
|
||||
@@ -936,6 +963,10 @@ export default function App() {
|
||||
setViewerPreview(null);
|
||||
setViewerError('');
|
||||
setIsViewerLoading(false);
|
||||
setIsModelSlicingEnabled(false);
|
||||
setModelStartPreview(null);
|
||||
setModelEndPreview(null);
|
||||
setModelMaskError('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -970,6 +1001,81 @@ export default function App() {
|
||||
return () => controller.abort();
|
||||
}, [libraryViewerItem?.id, viewerPlane, debouncedViewerSliceIndex, viewerWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryViewerItem || !viewerPreview?.count) return;
|
||||
setModelClipStart(current => Math.max(0, Math.min(viewerPreview.count - 1, current)));
|
||||
setModelClipEnd(current => {
|
||||
if (current <= 0) return viewerPreview.count - 1;
|
||||
return Math.max(0, Math.min(viewerPreview.count - 1, current));
|
||||
});
|
||||
}, [libraryViewerItem?.id, viewerPreview?.count]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryViewerItem || !isModelSlicingEnabled || !stlModel) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const makeUrl = (index: number) => (
|
||||
`${API_BASE}/api/library/reformat-preview?id=${encodeURIComponent(libraryViewerItem.id)}&plane=${encodeURIComponent(viewerPlane)}&index=${index}&window=${encodeURIComponent(viewerWindow)}&modelId=${encodeURIComponent(stlModel.modelId)}`
|
||||
);
|
||||
|
||||
setIsModelMaskLoading(true);
|
||||
setModelMaskError('');
|
||||
Promise.all([
|
||||
fetch(makeUrl(clampedModelStart), { signal: controller.signal }).then(async response => {
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || '起点帧 mask 生成失败');
|
||||
return data as LibraryViewerPreview;
|
||||
}),
|
||||
fetch(makeUrl(clampedModelEnd), { signal: controller.signal }).then(async response => {
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || '终点帧 mask 生成失败');
|
||||
return data as LibraryViewerPreview;
|
||||
}),
|
||||
])
|
||||
.then(([startPreview, endPreview]) => {
|
||||
setModelStartPreview(startPreview);
|
||||
setModelEndPreview(endPreview);
|
||||
})
|
||||
.catch(error => {
|
||||
if ((error as Error).name !== 'AbortError') setModelMaskError((error as Error).message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setIsModelMaskLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [libraryViewerItem?.id, isModelSlicingEnabled, stlModel?.modelId, viewerPlane, viewerWindow, clampedModelStart, clampedModelEnd]);
|
||||
|
||||
const uploadStlModel = () => {
|
||||
stlUploadInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleStlSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
setIsUploadingStl(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/model/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/sla',
|
||||
'x-file-name': encodeURIComponent(file.name),
|
||||
},
|
||||
body: await file.arrayBuffer(),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'STL 上传失败');
|
||||
setStlModel(data);
|
||||
setIsModelSlicingEnabled(true);
|
||||
showToast(`已载入 STL:${data.triangleCount || 0} 个三角面`);
|
||||
} catch (error) {
|
||||
showToast((error as Error).message);
|
||||
} finally {
|
||||
setIsUploadingStl(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = (userId: string, newPass: string) => {
|
||||
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
||||
setPwChangeInput('');
|
||||
@@ -1972,6 +2078,13 @@ export default function App() {
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={stlUploadInputRef}
|
||||
type="file"
|
||||
accept=".stl,model/stl,application/sla"
|
||||
className="hidden"
|
||||
onChange={handleStlSelected}
|
||||
/>
|
||||
|
||||
<div className="p-6 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6 overflow-y-auto min-h-0">
|
||||
<div className="space-y-5">
|
||||
@@ -2011,6 +2124,7 @@ export default function App() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!isModelSlicingEnabled && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">切片</p>
|
||||
@@ -2027,6 +2141,74 @@ export default function App() {
|
||||
className="w-full h-1.5 accent-blue-600 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">模型切分</p>
|
||||
<p className="text-[10px] font-bold text-slate-400 mt-1 truncate">
|
||||
{stlModel ? `${stlModel.name} · ${stlModel.triangleCount} 面` : '上传 STL 后启用真实 mask'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsModelSlicingEnabled(value => !value)}
|
||||
disabled={!stlModel}
|
||||
className={`px-3 py-2 rounded-xl text-[10px] font-black transition-all disabled:opacity-40 ${
|
||||
isModelSlicingEnabled
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{isModelSlicingEnabled ? '已启用' : '启用'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={uploadStlModel}
|
||||
disabled={isUploadingStl}
|
||||
className="w-full py-2.5 rounded-xl bg-slate-900 text-white text-xs font-black hover:bg-blue-600 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isUploadingStl ? '上传解析中...' : '上传 STL 模型'}
|
||||
</button>
|
||||
|
||||
{isModelSlicingEnabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-[10px] font-mono font-black">
|
||||
<span className="text-blue-600">起点 {clampedModelStart + 1}</span>
|
||||
<span className="text-orange-600">终点 {clampedModelEnd + 1}</span>
|
||||
</div>
|
||||
<div className="relative h-8">
|
||||
<div className="absolute left-0 right-0 top-3 h-1.5 rounded-full bg-slate-200"></div>
|
||||
<div
|
||||
className="absolute top-3 h-1.5 rounded-full bg-blue-500/60"
|
||||
style={{
|
||||
left: `${Math.min(clampedModelStart, clampedModelEnd) / Math.max(1, viewerFrameCount - 1) * 100}%`,
|
||||
right: `${100 - Math.max(clampedModelStart, clampedModelEnd) / Math.max(1, viewerFrameCount - 1) * 100}%`,
|
||||
}}
|
||||
></div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={viewerFrameCount - 1}
|
||||
value={clampedModelStart}
|
||||
onChange={event => setModelClipStart(parseInt(event.target.value, 10))}
|
||||
className="absolute inset-x-0 top-0 w-full h-8 appearance-none bg-transparent accent-blue-600 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={viewerFrameCount - 1}
|
||||
value={clampedModelEnd}
|
||||
onChange={event => setModelClipEnd(parseInt(event.target.value, 10))}
|
||||
className="absolute inset-x-0 top-0 w-full h-8 appearance-none bg-transparent accent-orange-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold text-slate-400">
|
||||
两个端点可交叉;显示时分别按起点帧和终点帧切 STL。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
@@ -2045,7 +2227,36 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="min-h-[360px] lg:min-h-[560px] bg-slate-950 rounded-2xl border border-slate-900 overflow-hidden flex items-center justify-center relative">
|
||||
{viewerPreview?.imageUrl && !viewerError ? (
|
||||
{isModelSlicingEnabled && stlModel ? (
|
||||
<div className="w-full h-full grid grid-cols-1 xl:grid-cols-2 gap-0">
|
||||
{[
|
||||
{ label: '起点帧', preview: modelStartPreview, color: 'text-blue-300' },
|
||||
{ label: '终点帧', preview: modelEndPreview, color: 'text-orange-300' },
|
||||
].map(item => (
|
||||
<div key={item.label} className="relative min-h-[330px] flex items-center justify-center border-slate-800 xl:border-l first:border-l-0">
|
||||
{item.preview?.imageUrl && !modelMaskError ? (
|
||||
<img src={`${API_BASE}${item.preview.imageUrl}`} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="text-center text-white/35">
|
||||
<ImageIcon size={38} className="mx-auto mb-3" />
|
||||
<p className="text-xs font-bold">{modelMaskError || '等待 STL mask'}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-4 top-4 px-3 py-2 rounded-xl bg-black/60 border border-white/10">
|
||||
<p className={`text-[10px] font-black ${item.color}`}>{item.label}</p>
|
||||
<p className="text-[10px] font-mono text-white/70 mt-0.5">
|
||||
{item.preview ? `${item.preview.index + 1} / ${item.preview.count}` : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute right-4 bottom-4 px-3 py-2 rounded-xl bg-black/60 border border-white/10">
|
||||
<p className="text-[10px] font-black text-white/70">
|
||||
MASK {item.preview?.maskPixels ? `${item.preview.maskPixels} px` : '无交集'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : viewerPreview?.imageUrl && !viewerError ? (
|
||||
<img src={`${API_BASE}${viewerPreview.imageUrl}`} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="text-center text-white/35">
|
||||
@@ -2053,7 +2264,7 @@ export default function App() {
|
||||
<p className="text-xs font-bold">{viewerError || '等待影像载入'}</p>
|
||||
</div>
|
||||
)}
|
||||
{isViewerLoading && (
|
||||
{(isViewerLoading || isModelMaskLoading) && (
|
||||
<div className="absolute top-4 right-4 px-3 py-1.5 rounded-lg bg-black/60 text-white text-[10px] font-black">
|
||||
载入中...
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user