From dcd6fe56c7b64530d5910a063644a6952e813253 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Wed, 20 May 2026 23:44:42 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-20-23-28-51=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=BA=93=E6=98=A0=E5=B0=84=E4=BA=A4=E4=BA=92=E4=B8=8E=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=8F=90=E7=A4=BA=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/server.ts | 46 +++- WebSite/src/components/ProjectLibrary.tsx | 252 ++++++++++++-------- WebSite/src/components/ReverseWorkspace.tsx | 36 ++- WebSite/src/index.css | 16 +- 工程分析/实现方案-2026-05-20-23-28-51.md | 58 +++++ 工程分析/测试方案-2026-05-20-23-28-51.md | 54 +++++ 工程分析/经验记录.md | 18 ++ 工程分析/需求分析-2026-05-20-23-28-51.md | 53 ++++ 8 files changed, 427 insertions(+), 106 deletions(-) create mode 100644 工程分析/实现方案-2026-05-20-23-28-51.md create mode 100644 工程分析/测试方案-2026-05-20-23-28-51.md create mode 100644 工程分析/需求分析-2026-05-20-23-28-51.md diff --git a/WebSite/server.ts b/WebSite/server.ts index 0b937fc..823ac25 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -243,6 +243,40 @@ function getProjectModelFilePath(project: ProjectRecord, fileName: string) { return path.join(getProjectModelDir(project), fileName); } +function getProjectDicomInfoCachePath(project: ProjectRecord) { + const dicomAssetDir = getProjectDicomDir(project); + const resolvedDir = path.resolve(dicomAssetDir); + const resolvedUploadDir = path.resolve(uploadDir); + if (!resolvedDir.startsWith(`${resolvedUploadDir}${path.sep}`)) { + return null; + } + return path.join(resolvedDir, '.revoxelseg-dicom-info.json'); +} + +function readCachedDicomInfo(project: ProjectRecord, files: string[]) { + const cachePath = getProjectDicomInfoCachePath(project); + if (!cachePath || !fs.existsSync(cachePath)) { + return null; + } + try { + const cached = JSON.parse(fs.readFileSync(cachePath, 'utf8')) as { files?: string[]; info?: unknown }; + if (!Array.isArray(cached.files) || cached.files.join('|') !== files.join('|') || !cached.info) { + return null; + } + return cached.info; + } catch { + return null; + } +} + +function writeCachedDicomInfo(project: ProjectRecord, files: string[], info: unknown) { + const cachePath = getProjectDicomInfoCachePath(project); + if (!cachePath) { + return; + } + fs.writeFileSync(cachePath, JSON.stringify({ generatedAt: now(), files, info }, null, 2)); +} + function clearProjectRuntimeCaches(projectId: string) { [...dicomPreviewCache.keys()].forEach((key) => { if (key.startsWith(`${projectId}:`)) { @@ -2391,6 +2425,8 @@ async function startServer() { project.dicomPath = toRepoRelativePath(targetDir); project.dicomCount = dicomFiles.length; project.segmentationResults = []; + const dicomInfo = createDicomInfo(project, dicomFiles); + writeCachedDicomInfo(project, dicomFiles, dicomInfo); } else { const stlFiles = listFiles(targetDir, '.stl'); project.modelPath = toRepoRelativePath(targetDir); @@ -2566,7 +2602,15 @@ async function startServer() { } try { - res.json(createDicomInfo(project, files)); + const cachedInfo = readCachedDicomInfo(project, files); + if (cachedInfo) { + res.json(cachedInfo); + return; + } + + const info = createDicomInfo(project, files); + writeCachedDicomInfo(project, files, info); + res.json(info); } catch (error) { res.status(422).json({ message: error instanceof Error ? error.message : 'DICOM 信息解析失败' }); } diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 16ebcf6..38f051d 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -25,6 +25,7 @@ import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, Segme import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectAssetImportKind, ProjectExportTarget, SegmentationExportMode } from '../lib/api'; import { FusionThreeView, + OverlayStats, VoxelizationMappingView, clearCachedProjectAssets, getCachedDicomFusionVolume, @@ -91,6 +92,12 @@ const defaultModelPose: ModelPose = { translateZ: 0, scale: 1, }; +const emptyOverlayStats: OverlayStats = { + activeModules: 0, + filledPixels: 0, + segmentCount: 0, + modules: [], +}; const modelPoseLimits: Record = { rotateX: { min: -180, max: 180 }, rotateY: { min: -180, max: 180 }, @@ -680,6 +687,8 @@ export default function ProjectLibrary({ const [dicomPreview, setDicomPreview] = useState(null); const [resultFusionVolume, setResultFusionVolume] = useState(null); const [resultFusionError, setResultFusionError] = useState(''); + const [resultOverlayStats, setResultOverlayStats] = useState(emptyOverlayStats); + const [resultVisibleModuleCount, setResultVisibleModuleCount] = useState(0); const [dicomInfo, setDicomInfo] = useState(null); const [dicomInfoError, setDicomInfoError] = useState(''); const [isDicomInfoOpen, setIsDicomInfoOpen] = useState(false); @@ -839,6 +848,19 @@ export default function ProjectLibrary({ return; } const kind: ProjectAssetImportKind = viewMode === 'model' ? 'stl' : 'dicom'; + const hasExistingAssets = kind === 'dicom' + ? (selectedProject.dicomCount ?? 0) > 0 + : (selectedProject.stlFiles?.length ?? selectedProject.modelCount ?? 0) > 0; + if (hasExistingAssets) { + const confirmed = window.confirm( + kind === 'dicom' + ? '当前项目已有 DICOM 影像。继续导入会覆盖项目库中的现有 DICOM 影像,并清空当前逆向分割结果,是否继续?' + : '当前项目已有 3D 模型。继续导入会覆盖项目库中的现有 STL 模型,并清空当前逆向分割结果,是否继续?', + ); + if (!confirmed) { + return; + } + } const input = importInputRef.current; if (!input) { setActionMessage('导入控件尚未就绪,请稍后重试'); @@ -1159,6 +1181,119 @@ export default function ProjectLibrary({ { id: 'model' as const, label: '3D 模型', icon: Box }, { id: 'mask' as const, label: '逆向分割结果', icon: Layers }, ]; + const renderMaskExportMenu = (widthClass = 'w-80') => ( +
+
+

导出内容

+ +
+
+ {exportOptions.map((option) => ( + + ))} +
+ {maskExportSelection.segmentation && ( +
+
+

分割类别范围

+ 附带 labels.json +
+
+ {segmentationScopeOptions.map((option) => ( + + ))} +
+
+

分割导出方式

+
+ {segmentationExportModeOptions.map((option) => ( + + ))} +
+
+
+ )} + +
+ ); + const renderResultOverlaySummary = () => ( +
+
+
+

Overlay Label Map

+

+ {resultOverlayStats.activeModules}/{resultVisibleModuleCount} 构件 · {resultOverlayStats.segmentCount} 边 · {resultOverlayStats.filledPixels} px +

+
+ 当前切片 +
+ {resultOverlayStats.modules.length ? ( +
+ {resultOverlayStats.modules.map((item) => ( +
+ + {item.name} + ID {item.partId} + {item.segmentCount} 边 + {item.filledPixels} px +
+ ))} +
+ ) : ( +
+ 当前切片暂无可见构件 +
+ )} +
+ ); return (
@@ -1312,7 +1447,19 @@ export default function ProjectLibrary({ > 进入逆向工作区 - {viewMode !== 'mask' && ( + {viewMode === 'mask' ? ( +
+ + {showMaskExportMenu && renderMaskExportMenu('w-80')} +
+ ) : ( - {showMaskExportMenu && ( -
-
-

导出内容

- -
-
- {exportOptions.map((option) => ( - - ))} -
- {maskExportSelection.segmentation && ( -
-
-

分割类别范围

- 附带 labels.json -
-
- {segmentationScopeOptions.map((option) => ( - - ))} -
-
-

分割导出方式

-
- {segmentationExportModeOptions.map((option) => ( - - ))} -
-
-
- )} - -
- )} -
+ {latestSegmentationResult && renderResultOverlaySummary()} )} diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 23f680e..42e0e7b 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -1257,7 +1257,7 @@ interface PlaneSegment { b: Point2D; } -interface OverlayStats { +export interface OverlayStats { activeModules: number; filledPixels: number; segmentCount: number; @@ -1881,6 +1881,7 @@ export function VoxelizationMappingView({ variant = 'workspace', toolbar, overlayPlacement, + onOverlayStatsChange, }: { project: Project | null; moduleStyles: Record; @@ -1893,7 +1894,8 @@ export function VoxelizationMappingView({ rotation: number; variant?: 'workspace' | 'library'; toolbar?: React.ReactNode; - overlayPlacement?: 'bottom' | 'side'; + overlayPlacement?: 'bottom' | 'side' | 'none'; + onOverlayStatsChange?: (stats: OverlayStats, visibleModuleCount: number) => void; }) { const baseCanvasRef = useRef(null); const overlayCanvasRef = useRef(null); @@ -1918,6 +1920,10 @@ export function VoxelizationMappingView({ const isLibraryVariant = variant === 'library'; const activeOverlayPlacement = overlayPlacement ?? (isLibraryVariant ? 'side' : 'bottom'); + useEffect(() => { + onOverlayStatsChange?.(overlayStats, visibleModuleCount); + }, [onOverlayStatsChange, overlayStats, visibleModuleCount]); + useEffect(() => { if (!project?.dicomCount) { setDicomPreview(null); @@ -2360,6 +2366,7 @@ export default function ReverseWorkspace({ const workspaceLoadProjectRef = useRef(''); const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null }); const poseImportInputRef = useRef(null); + const visualToolbarScrollRef = useRef(null); const saveToastTimerRef = useRef(null); const savedWorkspaceSnapshotRef = useRef(''); const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false }); @@ -2767,13 +2774,26 @@ export default function ReverseWorkspace({ } }; + const restoreVisualToolbarScroll = (scrollTop: number | null) => { + if (scrollTop === null) { + return; + } + window.requestAnimationFrame(() => { + if (visualToolbarScrollRef.current) { + visualToolbarScrollRef.current.scrollTop = scrollTop; + } + }); + }; + const nudgeModelPose = (key: ModelPoseKey, delta: number) => { + const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null; setModelPose((current) => ({ ...current, [key]: clampPoseValue(key, current[key] + delta), })); setSelectedPoseId('custom'); setPoseImportStatus(''); + restoreVisualToolbarScroll(scrollTop); }; const handlePoseInputChange = (key: ModelPoseKey, value: string) => { @@ -3378,7 +3398,7 @@ export default function ReverseWorkspace({
-
+

模型显示

@@ -3511,7 +3531,10 @@ export default function ReverseWorkspace({
{item.label}