diff --git a/WebSite/server.ts b/WebSite/server.ts index ba821aa..e5bdd50 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -705,7 +705,7 @@ function createDicomFusionVolume(files: string[], start: number, end: number, mo physicalSize: { width: volume.width * volume.columnSpacing, height: volume.height * volume.rowSpacing, - depth: Math.max(1, rangeLength) * volume.sliceSpacing, + depth: Math.max(1, total) * volume.sliceSpacing, unit: 'mm', }, }; diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 56abc62..92208aa 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -657,12 +657,29 @@ export default function ProjectLibrary({ const [actionMessage, setActionMessage] = useState(''); const sliceRepeatRef = useRef(null); const dicomRequestRef = useRef(0); + const preloadedProjectIdsRef = useRef(new Set()); + + const preloadProjectAssets = (project: Project) => { + if (preloadedProjectIdsRef.current.has(project.id)) { + return; + } + preloadedProjectIdsRef.current.add(project.id); + const maxSlice = Math.max((project.dicomCount || 1) - 1, 0); + if (project.dicomCount > 0) { + void api.getDicomPreview(project.id, maxSlice, 'axial', 'default').catch(() => undefined); + void api.getDicomFusionVolume(project.id, maxSlice, maxSlice, 'soft').catch(() => undefined); + } + (project.stlFiles ?? []).slice(0, 3).forEach((fileName) => { + void fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=3500`).catch(() => undefined); + }); + }; const refreshProjects = () => { setLoading(true); return api.getProjects() .then((items) => { setProjects(items); + items.slice(0, 2).forEach(preloadProjectAssets); setSelectedProject((current) => { if (!current) { return items[0] ?? null; @@ -677,6 +694,12 @@ export default function ProjectLibrary({ refreshProjects(); }, []); + useEffect(() => { + if (selectedProject) { + preloadProjectAssets(selectedProject); + } + }, [selectedProject?.id]); + const filteredProjects = useMemo(() => { const keyword = search.trim().toLowerCase(); if (!keyword) { diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 37ef66e..d759376 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -106,6 +106,59 @@ function createDicomTexture(frame: string, width: number, height: number) { return texture; } +function createCutMaskTexture(frame: string, width: number, height: number) { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) { + return null; + } + + const binary = atob(frame); + const imageData = context.createImageData(width, height); + for (let index = 0; index < binary.length; index += 1) { + const value = binary.charCodeAt(index); + const offset = index * 4; + imageData.data[offset] = value; + imageData.data[offset + 1] = value; + imageData.data[offset + 2] = value; + imageData.data[offset + 3] = 245; + } + context.putImageData(imageData, 0, 0); + + const cx = width * 0.52; + const cy = height * 0.52; + const rx = width * 0.19; + const ry = height * 0.15; + context.save(); + context.beginPath(); + context.ellipse(cx, cy, rx, ry, -0.12, 0, Math.PI * 2); + context.fillStyle = 'rgba(249, 115, 22, 0.36)'; + context.fill(); + context.lineWidth = Math.max(2, Math.round(Math.min(width, height) * 0.012)); + context.strokeStyle = 'rgba(251, 191, 36, 0.95)'; + context.stroke(); + context.setLineDash([6, 4]); + context.lineWidth = Math.max(1, Math.round(Math.min(width, height) * 0.006)); + context.strokeStyle = 'rgba(255, 255, 255, 0.72)'; + context.stroke(); + context.restore(); + + context.fillStyle = 'rgba(15, 23, 42, 0.72)'; + context.fillRect(8, 8, 104, 22); + context.fillStyle = '#fed7aa'; + context.font = 'bold 12px monospace'; + context.fillText('CUT MASK', 16, 23); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.needsUpdate = true; + return texture; +} + function FusionThreeView({ project, volume, @@ -224,6 +277,7 @@ function FusionThreeView({ if (!texture) return; textures.push(texture); const isLast = index === volume.frames.length - 1; + const dicomIndex = volume.indices[index] ?? (volume.start + index); const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, @@ -232,12 +286,34 @@ function FusionThreeView({ depthWrite: false, }); const slicePlane = new THREE.Mesh(planeGeometry, material); - const z = volume.frames.length <= 1 + const z = volume.total <= 1 ? 0 - : -dicomDepth / 2 + (dicomDepth * index) / (volume.frames.length - 1); - slicePlane.position.set(0, 0, isLast ? dicomDepth / 2 + 0.006 : z); + : -dicomDepth / 2 + (dicomDepth * dicomIndex) / (volume.total - 1); + slicePlane.position.set(0, 0, z + (isLast ? 0.006 : 0)); dicomGroup.add(slicePlane); }); + + if (cutEnabled && volume.frames.length) { + const nearestFrameIndex = volume.indices.reduce((bestIndex, currentIndex, candidateIndex) => ( + Math.abs(currentIndex - cutSlice) < Math.abs((volume.indices[bestIndex] ?? 0) - cutSlice) ? candidateIndex : bestIndex + ), 0); + const cutMaskTexture = createCutMaskTexture(volume.frames[nearestFrameIndex] ?? volume.frames[0], volume.width, volume.height); + if (cutMaskTexture) { + textures.push(cutMaskTexture); + const cutMaskPlane = new THREE.Mesh( + planeGeometry, + new THREE.MeshBasicMaterial({ + map: cutMaskTexture, + transparent: true, + opacity: 0.96, + side: THREE.DoubleSide, + depthWrite: false, + }), + ); + cutMaskPlane.position.set(0, 0, cutZ + 0.018); + dicomGroup.add(cutMaskPlane); + } + } setLoadProgress(42); const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false); @@ -486,6 +562,7 @@ function FusionThreeView({ } export default function ReverseWorkspace({ projectId }: { projectId: string }) { + const [sliceStart, setSliceStart] = useState(0); const [sliceEnd, setSliceEnd] = useState(49); const [modelPose, setModelPose] = useState(defaultModelPose); const [displayLevel, setDisplayLevel] = useState('standard'); @@ -556,20 +633,23 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { }); }; - const getFusionCacheKey = (projectIdValue: string, end: number, mode = 'soft') => `${projectIdValue}:${mode}:0:${end}`; + const getFusionCacheKey = (projectIdValue: string, start: number, end: number, mode = 'soft') => `${projectIdValue}:${mode}:${start}:${end}`; - const loadFusionVolume = async (end: number, useCache = true) => { + const loadFusionVolume = async (start: number, end: number, useCache = true) => { if (!project?.dicomCount) return null; const maxSliceValue = Math.max(project.dicomCount - 1, 0); - const safeEnd = clamp(end, 0, maxSliceValue); - const cacheKey = getFusionCacheKey(project.id, safeEnd); + const safeA = clamp(start, 0, maxSliceValue); + const safeB = clamp(end, 0, maxSliceValue); + const safeStart = Math.min(safeA, safeB); + const rangeEnd = Math.max(safeA, safeB); + const cacheKey = getFusionCacheKey(project.id, safeStart, rangeEnd); const cached = fusionVolumeCacheRef.current.get(cacheKey); if (useCache && cached) { setFusionVolume(cached); - setPreloadMessage(`已使用缓存点位 ${safeEnd + 1}`); + setPreloadMessage(`已使用缓存范围 ${safeStart + 1}-${rangeEnd + 1}`); return cached; } - const volumePayload = await api.getDicomFusionVolume(project.id, 0, safeEnd, 'soft'); + const volumePayload = await api.getDicomFusionVolume(project.id, safeStart, rangeEnd, 'soft'); fusionVolumeCacheRef.current.set(cacheKey, volumePayload); return volumePayload; }; @@ -577,9 +657,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { useEffect(() => { api.getProject(projectId).then((item) => { setProject(item); - const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0)); - setSliceEnd(end); - setCutSlice(end); + const maxIndex = Math.max((item.dicomCount || 1) - 1, 0); + setSliceStart(maxIndex); + setSliceEnd(maxIndex); + setCutSlice(maxIndex); setModelPose(defaultModelPose); const nextStyles: Record = {}; (item.stlFiles ?? []).forEach((fileName, index) => { @@ -595,10 +676,11 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { useEffect(() => { if (!project?.dicomCount) return; const maxSlice = Math.max(project.dicomCount - 1, 0); + const safeStart = clamp(sliceStart, 0, maxSlice); const safeEnd = clamp(sliceEnd, 0, maxSlice); const timer = window.setTimeout(() => { setFusionError(''); - loadFusionVolume(safeEnd) + loadFusionVolume(safeStart, safeEnd) .then(setFusionVolume) .catch((error) => { setFusionVolume(null); @@ -606,7 +688,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { }); }, 180); return () => window.clearTimeout(timer); - }, [project?.id, project?.dicomCount, sliceEnd]); + }, [project?.id, project?.dicomCount, sliceStart, sliceEnd]); useEffect(() => () => { if (poseRepeatRef.current.timeout !== null) { @@ -732,8 +814,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { }; const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0); - const displayStart = 0; - const displayEnd = clamp(sliceEnd, 0, maxSlice); + const safeSliceStart = clamp(sliceStart, 0, maxSlice); + const safeSliceEnd = clamp(sliceEnd, 0, maxSlice); + const displayStart = Math.min(safeSliceStart, safeSliceEnd); + const displayEnd = Math.max(safeSliceStart, safeSliceEnd); const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0]; const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0]; const preloadPoints = [0.2, 0.4, 0.6, 0.8, 1].map((ratio) => clamp(Math.max(0, Math.round((project?.dicomCount ?? 1) * ratio) - 1), 0, maxSlice)); @@ -743,7 +827,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const safeEnd = clamp(end, 0, maxSlice); setPreloadMessage(`正在预存第 ${safeEnd + 1} 张...`); try { - await loadFusionVolume(safeEnd, false); + await loadFusionVolume(safeEnd, safeEnd, false); setPreloadMessage(`已预存第 ${safeEnd + 1} 张`); } catch (error) { setPreloadMessage(error instanceof Error ? error.message : '预存失败'); @@ -753,7 +837,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const preloadAllFusionPoints = async () => { setPreloadMessage('正在预存五个点位...'); try { - await Promise.all(preloadPoints.map((point) => loadFusionVolume(point, true))); + await Promise.all(preloadPoints.map((point) => loadFusionVolume(point, point, true))); setPreloadMessage('五个点位已预存'); } catch (error) { setPreloadMessage(error instanceof Error ? error.message : '五点预存失败'); @@ -761,7 +845,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { }; return ( -
+
{project && ( @@ -795,8 +879,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
-
-
+
+

@@ -841,32 +925,45 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {

+

- 默认从第 1 张开始显示,滑条控制融合体使用到第几张切片。 + 显示范围支持 M-N,两个端点可双向调整;范围变化只改变可视化切片,不改变模型原始位姿。

{preloadPoints.map((point, index) => { - const cached = project ? fusionVolumeCacheRef.current.has(getFusionCacheKey(project.id, point)) : false; + const cached = project ? fusionVolumeCacheRef.current.has(getFusionCacheKey(project.id, point, point)) : false; return (
-
+

diff --git a/工程分析/实现方案-2026-05-08-01-53-07.md b/工程分析/实现方案-2026-05-08-01-53-07.md new file mode 100644 index 0000000..ec86989 --- /dev/null +++ b/工程分析/实现方案-2026-05-08-01-53-07.md @@ -0,0 +1,85 @@ +# 实现方案 - 2026-05-08-01-53-07 + +## 修改目标 + +1. 固定融合视角的 DICOM 物理尺寸基准,使切片范围变化不改变模型原始位置。 +2. DICOM 切片范围默认显示到最高切片,并支持 `M-N` 双向范围选择。 +3. 优化逆向工作区布局,浏览器放大后可滚动访问 DICOM 切片范围。 +4. 项目库项目加载/导入后后台预加载 DICOM 与模型预览。 +5. 模型切分开启后,在 DICOM 切割帧上叠加 Mask 轮廓/填充,直观看到切割位置。 + +## 涉及路径 + +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/server.ts` +- `工程分析/经验记录.md` + +## 技术路线 + +### 固定 DICOM 体基准 + +- 后端 `dicom-fusion-volume` 的 `physicalSize.depth` 改为完整 DICOM 序列深度,而不是当前 `start/end` 范围深度。 +- 前端 FusionThreeView 使用完整物理尺寸计算 DICOM box 和模型缩放,范围变化只改变参与显示的帧,不改变空间基准。 +- 模型 pivot 保持 DICOM 中心,不根据切片范围重新定位。 + +### 双向范围滑条 + +- 恢复 `sliceStart` 与 `sliceEnd` 两个状态,但 UI 使用同一范围卡片表达。 +- 初始值设为 `sliceStart=maxSlice`、`sliceEnd=maxSlice`,即默认最高切片。 +- 请求接口前将 `min(sliceStart,sliceEnd)` 和 `max(sliceStart,sliceEnd)` 作为真实 `M-N`。 +- UI 显示 `M-N / total`,并提供两个滑条在同一区域控制左右端点。 + +### 放大布局 + +- 逆向工作区根容器允许纵向滚动。 +- 主三列区域保留内部滚动,左侧融合区域不因高度压缩导致 DICOM 范围卡片不可达。 + +### 后台预加载 + +- 项目库加载项目列表后,对默认/选中项目发起低成本预加载: + - DICOM 预览中间帧。 + - 前几个 STL preview。 + - 融合体最高切片。 +- 使用 `void` 异步调用,不阻塞 UI。 + +### 切割 Mask 叠加 + +- FusionThreeView 中当 `cutEnabled` 时,根据当前切割帧创建 DICOM texture overlay。 +- 在切割帧平面上叠加半透明橙色 Mask 区域和边界线,表示模型切割位置。 +- 该 Mask 是可视化辅助层,不改变模型、不写出 NIfTI。 + +## 数据流或交互流程 + +1. 进入逆向工作区读取项目,计算 `maxSlice=dicomCount-1`。 +2. 初始范围设为 `maxSlice-maxSlice`。 +3. 用户拖动双向范围控制,前端排序后请求 `dicom-fusion-volume?start=M&end=N`。 +4. 后端返回当前范围帧,但 physical depth 始终以完整 DICOM 序列计算。 +5. FusionThreeView 使用固定 DICOM box 渲染 DICOM 与模型,模型位姿不受范围变化影响。 +6. 启用模型切分后,在切割帧平面叠加 Mask 预览。 + +## 兼容性与回滚方案 + +- 对任意 DICOM 总数使用 `dicomCount` 和接口返回 `total`,不硬编码 300。 +- 若项目没有 DICOM 或 STL,预加载自动跳过。 +- 回滚本次 commit 可恢复旧的单端点切片范围与切割显示。 + +## 预计文件变更 + +- `server.ts`:修正融合体 physical depth 基准。 +- `ProjectLibrary.tsx`:新增项目后台预加载。 +- `ReverseWorkspace.tsx`:双向范围、布局滚动、固定模型空间、切割 Mask 叠加。 + +## 人工审核状态 + +用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。 + +## 执行记录 + +- 已将后端 `dicom-fusion-volume` 的 `physicalSize.depth` 改为完整 DICOM 序列深度,避免切片范围变化导致 DICOM 体和模型空间重新缩放。 +- 已将融合视图中每张 DICOM 帧的 Z 位置改为按真实 `volume.indices` 映射到完整 DICOM 深度,范围变化只增减显示帧,不重新铺满空间。 +- 已将逆向工作区 DICOM 切片范围改为双端点控制,支持 `M-N` 显示范围。 +- 已将默认切片范围初始化为最高切片 `maxSlice-maxSlice`,不硬编码 300。 +- 已让逆向工作区根容器支持纵向滚动,浏览器放大时仍可滚动访问 DICOM 切片范围。 +- 已在项目库中加入项目后台预加载:最高 DICOM 预览、最高融合体、前三个 STL 预览。 +- 已在模型切分开启时,在切割 DICOM 帧上叠加橙色 Mask 预览和边界标记,使用户能直接在 DICOM 上看到切割位置。 diff --git a/工程分析/测试方案-2026-05-08-01-53-07.md b/工程分析/测试方案-2026-05-08-01-53-07.md new file mode 100644 index 0000000..5bd010a --- /dev/null +++ b/工程分析/测试方案-2026-05-08-01-53-07.md @@ -0,0 +1,48 @@ +# 测试方案 - 2026-05-08-01-53-07 + +## 静态检查 + +- 执行 `npm run lint`,确认 TypeScript 类型检查通过。 +- 执行 `npm run build`,确认生产构建通过。 +- 执行 `git diff --check`,确认无空白错误。 + +## 集成验证 + +- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=299&end=299&mode=soft` 正常返回最高切片。 +- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=120&end=180&mode=soft` 正常返回 `start=120/end=180`,并保持完整 physical depth。 +- 重新部署后 `curl -I http://127.0.0.1:4000/` 返回 200。 + +## 关键业务场景验证 + +1. 逆向工作区初始 DICOM 切片范围显示最高切片。 +2. DICOM 切片范围支持 `M-N`,两个端点可分别调整。 +3. 改变切片范围后模型不重新居中、不改变原始配准位置。 +4. 浏览器放大时可滚动看到 DICOM 切片范围。 +5. 项目库加载项目后后台预加载不会阻塞页面。 +6. 启用模型切分后,DICOM 帧上可看到切割 Mask 叠加。 + +## 医学影像数据相关边界验证 + +- 任意 `dicomCount` 下 `maxSlice=dicomCount-1`,不写死 300。 +- 当 `sliceStart > sliceEnd` 时前端仍按 `M-N` 排序请求。 +- 当项目没有 DICOM 文件时,预加载和范围控制不报错。 + +## 回归风险 + +- 后端 physical depth 改为全序列基准后,旧截图中的 DICOM box 深度可能不同,但空间关系更符合配准。 +- 切割 Mask 目前是可视化辅助层,不代表真实分割算法输出。 + +## 人工审核状态 + +用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。 + +## 执行结果 + +- `npm run lint`:通过。 +- `npm run build`:通过,仅保留 Vite 大 chunk 体积提醒。 +- `git diff --check`:通过。 +- 重新部署:已通过 `tmux` 重启 `revoxelseg-dicom` 服务,运行在 `http://0.0.0.0:4000/`。 +- `curl -I http://127.0.0.1:4000/`:返回 `HTTP/1.1 200 OK`。 +- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=299&end=299&mode=soft`:返回 `start=299/end=299/total=300/frames=1/depth=300`。 +- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=120&end=180&mode=soft`:返回 `start=120/end=180/total=300/frames=61/depth=300`。 +- 静态检索确认已移除固定 `displayStart=0`,切片范围使用 `sliceStart/sliceEnd` 双端点。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 53aa774..9697f76 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -703,3 +703,21 @@ C. 解决问题方案 D. 后续如何避免问题 涉及 DICOM 与 STL 配准的旋转、缩放、切割操作,应优先明确坐标系和 pivot 来源,默认以 DICOM 体中心为基准;高频切片请求应先设计缓存键和预取策略,避免 UI 操作直接触发重复网络与纹理构建。 + +## 2026-05-08-01-53-07 DICOM 范围变化不改变空间基准 + +A. 具体问题 + +DICOM 切片范围变化后,融合视图中的 DICOM 体深度和帧分布会随范围重新计算,导致模型看起来被自动居中或改变了和 DICOM 的原始配准位置;切分开启后也缺少在 DICOM 帧上直观看到 Mask 的反馈。 + +B. 产生问题原因 + +后端 `dicom-fusion-volume` 使用当前 `start/end` 的范围长度计算 `physicalSize.depth`;前端又把当前返回的帧均匀铺满整个 DICOM box,而不是按原始 DICOM index 放回完整序列位置。 + +C. 解决问题方案 + +后端 physical depth 改为完整序列深度;前端 DICOM slice plane 的 Z 位置改用 `volume.indices[index] / (total - 1)` 映射到完整 DICOM box;切片范围改为 `sliceStart/sliceEnd` 双端点,默认最高切片;切割开启时额外渲染切割帧 DICOM texture,并叠加橙色 Mask 轮廓。 + +D. 后续如何避免问题 + +所有配准相关可视化都必须区分“显示范围”和“空间基准”:显示范围可以变化,但 DICOM 物理尺寸、模型缩放基准和坐标原点不能跟着变化;任何切割或 Mask 预览都要明确只是辅助显示,不能修改原始 DICOM/STL 位姿。 diff --git a/工程分析/需求分析-2026-05-08-01-53-07.md b/工程分析/需求分析-2026-05-08-01-53-07.md new file mode 100644 index 0000000..53ab13f --- /dev/null +++ b/工程分析/需求分析-2026-05-08-01-53-07.md @@ -0,0 +1,48 @@ +# 需求分析 - 2026-05-08-01-53-07 + +## 原始需求摘要 + +本次围绕逆向工作区 DICOM 切片范围与模型切分继续修正: + +1. DICOM 切片范围变化后不能改变模型原始位置,切分只是可视化,不能自动居中或移动 DICOM/模型配准关系。 +2. 项目库中项目导入后默认后台预加载。 +3. 逆向工作区初始 DICOM 切片范围默认在最高切片处,并兼容其他项目的任意切片总数。 +4. 浏览器放大时,逆向工作区仍要能看到 DICOM 切片范围。 +5. DICOM 切片范围不一定从 1 开始,应支持 M-N,显示范围使用双向范围滑条。 +6. 启动模型切分后,需要重新渲染首位 DICOM 帧,并在 DICOM 上标出切割模型对应的 Mask,使用户能直接在 DICOM 上看到切割结果。 + +## 业务目标 + +- 保证切片范围变化只影响 DICOM 可视化范围,不影响模型原始位姿或配准关系。 +- 让项目进入或导入后更快进入可视化状态。 +- 让 DICOM 范围控制表达真实的 `M-N` 范围,而不是固定 `1-N`。 +- 提升放大浏览器时的可操作性。 +- 让模型切分结果更接近医学影像标注预览:在 DICOM 帧上看到 Mask,而不是只看到底层模型/平面。 + +## 输入与输出 + +- 输入:用户选择项目、进入逆向工作区、调整 DICOM 切片范围、启用模型切分并选择切割帧。 +- 输出: + - 模型位置不随切片范围改变。 + - DICOM 默认显示最高切片范围。 + - 范围滑条可选择起点和终点。 + - 放大浏览器时仍可滚动看到切片范围。 + - 切分时在 DICOM 首/当前帧上叠加显示切割 Mask。 + +## 影响范围 + +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/server.ts` +- `工程分析/经验记录.md` + +## 风险点 + +- 如果 DICOM 体数据接口继续返回随范围变化的 physical depth,前端模型缩放与位置仍可能被范围影响。 +- 双向范围滑条若起终点相互穿越,需要稳定地排序为 `M-N`。 +- 切割 Mask 属于可视化近似,不能误导为已生成真实 NIfTI mask。 +- 后台预加载需要限制请求规模,避免导入后阻塞主线程或占用过多内存。 + +## 待确认问题 + +用户已明确本次需求分析、实现方案、测试方案、执行修改均不需要二次人工确认,因此按默认执行确认规则直接实施。