diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 906bc86..545d6b0 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -5,12 +5,14 @@ import { Download, Rotate3d, AlertCircle, - Play, + ChevronLeft, + ChevronRight, Eye, + Layers, Save, } from 'lucide-react'; import * as THREE from 'three'; -import { DicomFusionVolume, ModuleStyle, Project } from '../types'; +import { DicomFusionVolume, DicomPreview, ModuleStyle, Project } from '../types'; import { api, downloadMask } from '../lib/api'; interface ModelPose { @@ -792,9 +794,398 @@ function CutSectionPreview({ ); } +interface ModelBounds { + min: { x: number; y: number; z: number }; + max: { x: number; y: number; z: number }; +} + +function getPayloadBounds(payload: ModelPreviewPayload): ModelBounds | null { + if (payload.bounds) { + return payload.bounds; + } + + if (payload.vertices.length < 3) { + return null; + } + + const bounds: ModelBounds = { + min: { x: Infinity, y: Infinity, z: Infinity }, + max: { x: -Infinity, y: -Infinity, z: -Infinity }, + }; + + for (let index = 0; index < payload.vertices.length; index += 3) { + const x = payload.vertices[index]; + const y = payload.vertices[index + 1]; + const z = payload.vertices[index + 2]; + bounds.min.x = Math.min(bounds.min.x, x); + bounds.min.y = Math.min(bounds.min.y, y); + bounds.min.z = Math.min(bounds.min.z, z); + bounds.max.x = Math.max(bounds.max.x, x); + bounds.max.y = Math.max(bounds.max.y, y); + bounds.max.z = Math.max(bounds.max.z, z); + } + + return Number.isFinite(bounds.min.x) ? bounds : null; +} + +function getGlobalModelBounds(files: string[], previews: Record) { + const bounds: ModelBounds = { + min: { x: Infinity, y: Infinity, z: Infinity }, + max: { x: -Infinity, y: -Infinity, z: -Infinity }, + }; + let hasBounds = false; + + files.forEach((fileName) => { + const payloadBounds = previews[fileName] ? getPayloadBounds(previews[fileName]) : null; + if (!payloadBounds) { + return; + } + hasBounds = true; + bounds.min.x = Math.min(bounds.min.x, payloadBounds.min.x); + bounds.min.y = Math.min(bounds.min.y, payloadBounds.min.y); + bounds.min.z = Math.min(bounds.min.z, payloadBounds.min.z); + bounds.max.x = Math.max(bounds.max.x, payloadBounds.max.x); + bounds.max.y = Math.max(bounds.max.y, payloadBounds.max.y); + bounds.max.z = Math.max(bounds.max.z, payloadBounds.max.z); + }); + + return hasBounds ? bounds : null; +} + +function drawDicomBaseLayer(canvas: HTMLCanvasElement, preview: DicomPreview) { + canvas.width = preview.width; + canvas.height = preview.height; + const context = canvas.getContext('2d'); + if (!context) { + return; + } + + const binary = atob(preview.pixels); + const imageData = context.createImageData(preview.width, preview.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] = 255; + } + context.putImageData(imageData, 0, 0); +} + +function drawVoxelOverlayLayer( + canvas: HTMLCanvasElement, + preview: DicomPreview, + files: string[], + previews: Record, + moduleStyles: Record, + slice: number, + totalSlices: number, +) { + canvas.width = preview.width; + canvas.height = preview.height; + const context = canvas.getContext('2d'); + if (!context) { + return { activeModules: 0, paintedTriangles: 0 }; + } + + context.clearRect(0, 0, preview.width, preview.height); + const globalBounds = getGlobalModelBounds(files, previews); + if (!globalBounds) { + return { activeModules: 0, paintedTriangles: 0 }; + } + + const spanX = Math.max(globalBounds.max.x - globalBounds.min.x, 0.001); + const spanY = Math.max(globalBounds.max.y - globalBounds.min.y, 0.001); + const spanZ = Math.max(globalBounds.max.z - globalBounds.min.z, 0.001); + const normalizedSlice = totalSlices <= 1 ? 0.5 : clamp(slice, 0, totalSlices - 1) / (totalSlices - 1); + const targetZ = globalBounds.min.z + normalizedSlice * spanZ; + const sliceBand = Math.max(spanZ / Math.max(totalSlices, 1) * 1.85, spanZ * 0.014, 0.001); + const paddingX = preview.width * 0.08; + const paddingY = preview.height * 0.08; + const drawableWidth = Math.max(preview.width - paddingX * 2, 1); + const drawableHeight = Math.max(preview.height - paddingY * 2, 1); + const mapX = (x: number) => paddingX + ((x - globalBounds.min.x) / spanX) * drawableWidth; + const mapY = (y: number) => preview.height - paddingY - ((y - globalBounds.min.y) / spanY) * drawableHeight; + let activeModules = 0; + let paintedTriangles = 0; + + context.save(); + context.lineJoin = 'round'; + context.lineCap = 'round'; + context.globalCompositeOperation = 'source-over'; + + files.forEach((fileName, index) => { + const payload = previews[fileName]; + const style = moduleStyles[fileName] ?? { + visible: true, + color: moduleColors[index % moduleColors.length], + opacity: 0.72, + partId: index + 1, + }; + + if (!payload || style.visible === false) { + return; + } + + let modulePainted = false; + context.fillStyle = style.color; + context.strokeStyle = style.color; + context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.5; + context.lineWidth = Math.max(preview.width, preview.height) * 0.0015; + + for (let vertexIndex = 0; vertexIndex < payload.vertices.length; vertexIndex += 9) { + const z1 = payload.vertices[vertexIndex + 2]; + const z2 = payload.vertices[vertexIndex + 5]; + const z3 = payload.vertices[vertexIndex + 8]; + const minZ = Math.min(z1, z2, z3); + const maxZ = Math.max(z1, z2, z3); + + if (minZ > targetZ + sliceBand || maxZ < targetZ - sliceBand) { + continue; + } + + context.beginPath(); + context.moveTo(mapX(payload.vertices[vertexIndex]), mapY(payload.vertices[vertexIndex + 1])); + context.lineTo(mapX(payload.vertices[vertexIndex + 3]), mapY(payload.vertices[vertexIndex + 4])); + context.lineTo(mapX(payload.vertices[vertexIndex + 6]), mapY(payload.vertices[vertexIndex + 7])); + context.closePath(); + context.fill(); + context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.72; + context.stroke(); + context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.5; + modulePainted = true; + paintedTriangles += 1; + } + + if (modulePainted) { + activeModules += 1; + } + }); + + context.restore(); + return { activeModules, paintedTriangles }; +} + +function VoxelizationMappingView({ + project, + moduleStyles, + detailLimit, + slice, + totalSlices, + onSliceChange, +}: { + project: Project | null; + moduleStyles: Record; + detailLimit: number; + slice: number; + totalSlices: number; + onSliceChange: (slice: number) => void; +}) { + const baseCanvasRef = useRef(null); + const overlayCanvasRef = useRef(null); + const [dicomPreview, setDicomPreview] = useState(null); + const [modelPreviews, setModelPreviews] = useState>({}); + const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片'); + const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射'); + const [overlayStats, setOverlayStats] = useState({ activeModules: 0, paintedTriangles: 0 }); + const maxSlice = Math.max(totalSlices - 1, 0); + const safeSlice = clamp(slice, 0, maxSlice); + const stlFiles = project?.stlFiles ?? []; + const visibleModuleCount = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false).length; + + useEffect(() => { + if (!project?.dicomCount) { + setDicomPreview(null); + setDicomStatus('没有可显示的 DICOM 切片'); + return; + } + + let disposed = false; + setDicomStatus('正在载入 DICOM Base Layer...'); + api.getDicomPreview(project.id, safeSlice, 'axial', 'soft') + .then((preview) => { + if (disposed) return; + setDicomPreview(preview); + setDicomStatus('DICOM Base Layer 已就绪'); + }) + .catch((error) => { + if (disposed) return; + setDicomPreview(null); + setDicomStatus(error instanceof Error ? error.message : 'DICOM 切片载入失败'); + }); + + return () => { + disposed = true; + }; + }, [project?.id, project?.dicomCount, safeSlice]); + + useEffect(() => { + if (!project || !stlFiles.length) { + setModelPreviews({}); + setOverlayStatus('当前项目没有 STL 构件'); + return; + } + + let disposed = false; + setOverlayStatus('正在载入 STL 构件层级...'); + Promise.allSettled(stlFiles.map((fileName) => ( + fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 72000)}`) + .then((response) => { + if (!response.ok) { + throw new Error('STL 构件预览加载失败'); + } + return response.json() as Promise; + }) + .then((payload) => ({ fileName, payload })) + ))).then((results) => { + if (disposed) return; + const nextPreviews: Record = {}; + results.forEach((result) => { + if (result.status === 'fulfilled') { + nextPreviews[result.value.fileName] = result.value.payload; + } + }); + setModelPreviews(nextPreviews); + setOverlayStatus(Object.keys(nextPreviews).length ? 'Overlay Label Map 已就绪' : 'Overlay Layer 无可用 STL 数据'); + }); + + return () => { + disposed = true; + }; + }, [project?.id, stlFiles.join('|'), detailLimit]); + + useEffect(() => { + const canvas = baseCanvasRef.current; + if (!canvas || !dicomPreview) { + return; + } + drawDicomBaseLayer(canvas, dicomPreview); + }, [dicomPreview]); + + useEffect(() => { + const canvas = overlayCanvasRef.current; + if (!canvas || !dicomPreview) { + return; + } + const stats = drawVoxelOverlayLayer( + canvas, + dicomPreview, + stlFiles, + modelPreviews, + moduleStyles, + safeSlice, + Math.max(totalSlices, 1), + ); + setOverlayStats(stats); + }, [dicomPreview, stlFiles.join('|'), modelPreviews, JSON.stringify(moduleStyles), safeSlice, totalSlices]); + + const stepSlice = (delta: number) => { + onSliceChange(clamp(safeSlice + delta, 0, maxSlice)); + }; + const slicePercent = maxSlice > 0 ? (safeSlice / maxSlice) * 100 : 0; + + return ( +
+
+
+ + Base DICOM + + + Overlay Label Map + +
+
+ Z {safeSlice + 1}/{Math.max(totalSlices, 1)} +
+ {dicomPreview ? ( +
+ + +
+ ) : ( +
+ {dicomStatus} +
+ )} +
+
+ {overlayStatus} + + {overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.paintedTriangles} 面 + +
+
+ {stlFiles.map((fileName, index) => { + const style = moduleStyles[fileName] ?? { + visible: true, + color: moduleColors[index % moduleColors.length], + opacity: 0.72, + partId: index + 1, + }; + return ( +
+ + {fileName.replace(/\.stl$/i, '')} + ID {style.partId} +
+ ); + })} +
+
+
+ +
+
+

Slice Navigator

+ + {safeSlice + 1} / {Math.max(totalSlices, 1)} + +
+
+ +
+
+
+ onSliceChange(Number(event.target.value))} + className="mapping-slice-input" + aria-label="逆向分割映射视图切片导航" + /> +
+ +
+
+
+ ); +} + export default function ReverseWorkspace({ projectId }: { projectId: string }) { const [sliceStart, setSliceStart] = useState(0); const [sliceEnd, setSliceEnd] = useState(49); + const [mappingSlice, setMappingSlice] = useState(0); const [modelPose, setModelPose] = useState(defaultModelPose); const [displayLevel, setDisplayLevel] = useState('standard'); const [dicomOpacityLevel, setDicomOpacityLevel] = useState('low'); @@ -879,6 +1270,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const maxIndex = Math.max((item.dicomCount || 1) - 1, 0); setSliceStart(0); setSliceEnd(maxIndex); + setMappingSlice(maxIndex); setModelPose(defaultModelPose); const nextStyles: Record = {}; (item.stlFiles ?? []).forEach((fileName, index) => { @@ -1034,6 +1426,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0); const safeSliceStart = clamp(sliceStart, 0, maxSlice); const safeSliceEnd = clamp(sliceEnd, 0, maxSlice); + const safeMappingSlice = clamp(mappingSlice, 0, maxSlice); const displayStart = Math.min(safeSliceStart, safeSliceEnd); const displayEnd = Math.max(safeSliceStart, safeSliceEnd); const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0; @@ -1416,8 +1809,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {

- - Mask 展示 + + 逆向分割映射视图

-
diff --git a/WebSite/src/index.css b/WebSite/src/index.css index 4d298ad..a2cd6ee 100644 --- a/WebSite/src/index.css +++ b/WebSite/src/index.css @@ -59,3 +59,60 @@ .dicom-range-input:active::-moz-range-thumb { cursor: grabbing; } + +.mapping-slice-input { + appearance: none; + -webkit-appearance: none; + background: transparent; + height: 100%; + inset: 0; + position: absolute; + width: 100%; +} + +.mapping-slice-input:focus { + outline: none; +} + +.mapping-slice-input::-webkit-slider-runnable-track { + background: transparent; + border: 0; + height: 8px; +} + +.mapping-slice-input::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + background: #22d3ee; + border: 3px solid #0f172a; + border-radius: 9999px; + box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45); + cursor: grab; + height: 22px; + margin-top: -7px; + width: 22px; +} + +.mapping-slice-input::-moz-range-track { + background: transparent; + border: 0; + height: 8px; +} + +.mapping-slice-input::-moz-range-thumb { + background: #22d3ee; + border: 3px solid #0f172a; + border-radius: 9999px; + box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45); + cursor: grab; + height: 16px; + width: 16px; +} + +.mapping-slice-input:active::-webkit-slider-thumb { + cursor: grabbing; +} + +.mapping-slice-input:active::-moz-range-thumb { + cursor: grabbing; +} diff --git a/工程分析/实现方案-2026-05-19-23-47-31.md b/工程分析/实现方案-2026-05-19-23-47-31.md new file mode 100644 index 0000000..da0d72a --- /dev/null +++ b/工程分析/实现方案-2026-05-19-23-47-31.md @@ -0,0 +1,57 @@ +# 实现方案-2026-05-19-23-47-31 + +## 实现方案文档路径 + +`工程分析/实现方案-2026-05-19-23-47-31.md` + +## 修改目标 + +将逆向工作区右侧 `Mask 展示` 改造成二维多图层“逆向分割映射视图”,实现 DICOM Base Layer、STL 逆向投影 Overlay Label Map、构件层级属性联动和独立 Slice Navigator。 + +## 涉及路径 + +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/src/index.css` +- `工程分析/需求分析-2026-05-19-23-47-31.md` +- `工程分析/实现方案-2026-05-19-23-47-31.md` +- `工程分析/测试方案-2026-05-19-23-47-31.md` +- `工程分析/经验记录.md` + +## 技术路线 + +1. 在 `ReverseWorkspace.tsx` 中新增独立 `mappingSlice` 状态。 +2. 将右侧 `CutSectionPreview` 替换为 `VoxelizationMappingView`。 +3. `VoxelizationMappingView` 使用两个叠放 Canvas: + - Base Canvas 绘制 `api.getDicomPreview(project.id, mappingSlice, 'axial', 'soft')` 返回的 DICOM 灰度像素。 + - Overlay Canvas 根据 STL preview 顶点、当前切片 Z 位置和 `moduleStyles` 生成 Label Map 投影。 +4. Overlay 绘制规则: + - 对每个可见 STL 构件读取颜色、透明度、`partId`。 + - 将 STL 顶点 bounds 归一化映射到 DICOM canvas。 + - 只绘制与当前 Z 切片带宽相交的三角面。 + - 隐藏构件不绘制,透明度随构件层级滑条变化。 +5. 在右侧视图底部添加专属 Slice Navigator: + - 独立范围为 `0 ~ project.dicomCount - 1`。 + - 支持拖动和左右单步按钮。 + - 不修改左侧 DICOM 范围,也不影响三维融合切分状态。 +6. 保留 NII/NII.GZ 导出按钮。 +7. 为新滑条补充稳定尺寸和样式,避免 UI 抖动。 + +## 兼容性与回滚方案 + +- 不修改后端接口和数据结构,回滚时只需恢复 `ReverseWorkspace.tsx` 与 `index.css`。 +- 如果 STL preview 加载失败,右侧仍显示 DICOM Base Layer,并显示 Overlay 状态提示。 +- 如果 DICOM preview 加载失败,显示加载或错误状态,不影响左侧三维融合视图。 + +## 预计文件变更 + +- 更新 `ReverseWorkspace.tsx`:新增二维映射视图、独立切片状态、右侧标题与图层联动。 +- 更新 `index.css`:新增独立 Slice Navigator 滑条样式。 +- 新增本次三份工程文档。 +- 更新 `经验记录.md`。 + +## 提交与部署策略 + +- 仅暂存本次代码和文档文件。 +- 提交信息使用:`2026-05-19-23-47-31 优化逆向分割映射视图` +- 运行 `npm run lint`、`npm run build`。 +- 重新部署 `tmux` 会话 `revoxelseg-dicom`,验证 `http://127.0.0.1:4000/api/health` 和首页响应。 diff --git a/工程分析/测试方案-2026-05-19-23-47-31.md b/工程分析/测试方案-2026-05-19-23-47-31.md new file mode 100644 index 0000000..4f8ab62 --- /dev/null +++ b/工程分析/测试方案-2026-05-19-23-47-31.md @@ -0,0 +1,63 @@ +# 测试方案-2026-05-19-23-47-31 + +## 测试方案文档路径 + +`工程分析/测试方案-2026-05-19-23-47-31.md` + +## 静态检查 + +- 在 `WebSite/` 下执行 `npm run lint`。 + +## 构建检查 + +- 在 `WebSite/` 下执行 `npm run build`。 + +## 关键业务场景验证 + +- 打开逆向工作区,确认右侧标题为“逆向分割映射视图”。 +- 确认右侧视图显示 DICOM Base Layer,而不是纯 STL 三维实体切面。 +- 确认 Overlay Layer 会显示与 STL 构件对应的彩色 Label Map。 +- 在中间“构件层级”面板修改构件颜色,右侧 Overlay 即时变色。 +- 修改构件透明度,右侧 Overlay 透明度即时变化。 +- 隐藏某个构件,右侧 Overlay 中对应构件消失。 +- 调整构件 `ID` 后,右侧图例中的 Label ID 与构件层级保持一致。 +- 拖动右侧 Slice Navigator,DICOM 切片和 Overlay 逐层切换。 +- 确认右侧 Slice Navigator 不改变左侧 DICOM 切片范围。 + +## 医学影像数据相关边界验证 + +- DICOM 切片总数为 0 或项目加载中时,右侧应显示加载/空状态。 +- STL preview 加载失败时,DICOM Base Layer 仍可显示。 +- 切片序号必须 clamp 到 `1 ~ dicomCount`。 +- Canvas Base Layer 与 Overlay Layer 必须尺寸一致。 + +## 部署验证 + +- 重新部署后验证: + - `curl http://127.0.0.1:4000/api/health` + - `curl -I http://127.0.0.1:4000/` + +## Git/Gitea 备份验证 + +- 显式暂存本次相关代码和文档,避免提交历史删除状态。 +- 创建包含时间戳和描述的 commit。 +- 尝试推送到 `origin/main`;若仍因 Gitea HTTP 凭据失败,则记录本地 commit 已完成、远端推送未完成。 + +## 回归关注点 + +- 右侧视图改为 2D Canvas 后,不能影响左侧 `FusionThreeView`。 +- 构件层级状态仍通过 `api.updateProjectModuleStyles` 持久化。 +- 不引入新的后端依赖。 + +## 实际执行结果 + +- `npm run lint`:通过。 +- `npm run build`:通过;Vite 仍提示大 chunk 警告,但构建成功。 +- DICOM preview 接口验证:`/api/projects/head-ct-demo/dicom-preview?slice=0&plane=axial&mode=soft` 返回 `200`。 +- STL preview 接口验证:`/api/projects/head-ct-demo/models/%E5%A4%B4%E9%83%A8.stl/preview?limit=72000` 返回 `200`。 +- 健康检查:`/api/health` 返回 `ok: true`。 +- 重新部署:已重启 `tmux` 会话 `revoxelseg-dicom`,服务日志显示 `ReVoxelSeg DICOM server ready at http://0.0.0.0:4000/`。 +- 部署后健康检查:`curl http://127.0.0.1:4000/api/health` 返回 `ok: true`。 +- 部署后首页验证:`curl -I http://127.0.0.1:4000/` 返回 `HTTP/1.1 200 OK`。 +- Git 本地备份 commit:已创建本次修改备份 commit,提交信息为 `2026-05-19-23-47-31 优化逆向分割映射视图`。 +- Gitea 远端推送:执行 `git push origin main` 仍失败,原因是 HTTP 远端 `http://192.168.31.5:5002` 无法读取用户名;未在命令行拼接或保存凭据。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 5692ecd..66cf146 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -847,3 +847,39 @@ C. 解决问题方案 D. 后续如何避免问题 需要 Gitea 远端备份前,先确认远端认证方式。优先使用安全的凭据助手、SSH remote 或用户已配置好的 token;不要把账号密码写进 commit、文档、remote URL 或 shell 历史。 + +## 2026-05-19-23-47-31 逆向分割映射视图的数据来源边界 + +A. 具体问题 + +用户要求右侧二维视图展示由三维 STL 模型逆向推导的分割掩码 Label Map,并与构件层级严格对应;但当前后端尚未提供医学级真实体素化 Label Map 文件。 + +B. 产生问题原因 + +现有系统的真实数据能力主要是 DICOM preview、DICOM fusion volume 和 STL preview 三角面抽样,历史版本的右侧区域也已从伪二维 Mask 改为 STL 实体切面。若直接绘制任意彩色区域,会再次变成无来源 Mask。 + +C. 解决问题方案 + +右侧新建 `VoxelizationMappingView`,底层用 DICOM preview 灰度像素作为 Base Layer,上层只根据 STL preview 顶点、当前 Z 切片位置和构件 `moduleStyles` 投影绘制 Overlay Label Map。每个彩色覆盖区域都来自具体 STL 构件,并复用构件颜色、透明度、显示隐藏和 `partId`。 + +D. 后续如何避免问题 + +在真实体素化服务接入前,所有 Label Map 类可视化必须明确使用 STL 几何或后端生成结果作为来源;不要重新引入无数据来源的演示 Mask。后续若增加医学级体素化,应保留当前 Base/Overlay UI,但将 Overlay 数据源替换为后端真实 Label Map。 + +## 2026-05-19-23-47-31 右侧独立切片导航与左侧范围状态分离 + +A. 具体问题 + +右侧二维映射视图需要逐层浏览单张 DICOM 切片,而左侧三维融合视图使用的是 DICOM 切片范围和模型切分区间;如果共用同一状态,拖动右侧滑条会破坏左侧融合范围。 + +B. 产生问题原因 + +旧工作区已经有 `sliceStart/sliceEnd` 双端点状态,用于三维 DICOM 范围和 STL clipping plane。右侧新增 Slice Navigator 是单切片校验动作,语义不同。 + +C. 解决问题方案 + +新增独立 `mappingSlice` 状态传入 `VoxelizationMappingView`,右侧 Slice Navigator 只修改该状态;左侧 `sliceStart/sliceEnd` 继续驱动三维融合范围和模型切分,两套状态互不覆盖。 + +D. 后续如何避免问题 + +新增影像浏览控件前先判断其控制对象是“单切片位置”还是“显示范围/切割范围”。单切片校验使用独立 slice 状态,范围切割使用起止端点状态,避免不同视图之间产生隐式联动。 diff --git a/工程分析/需求分析-2026-05-19-23-47-31.md b/工程分析/需求分析-2026-05-19-23-47-31.md new file mode 100644 index 0000000..27d73a6 --- /dev/null +++ b/工程分析/需求分析-2026-05-19-23-47-31.md @@ -0,0 +1,57 @@ +# 需求分析-2026-05-19-23-47-31 + +## 开始时间 + +2026-05-19-23-47-31 + +## 原始需求摘要 + +用户要求对逆向工作区右侧视图做产品润色:将“Mask 展示”重命名为“逆向分割映射视图”或“体素化映射视图”;明确该区域以二维 DICOM 切片为底图,叠加由三维 STL 模型逆向推导生成的二维分割掩码 Label Map;覆盖层颜色、透明度、显示隐藏状态必须与中间“可视化工具栏”的“构件层级”实时联动;右侧视图还需要独立 Slice Navigator,支持逐层浏览当前 Z 轴 DICOM 切片及对应分割结果。 + +## 业务目标 + +- 将右侧结果区从“STL 实体切面”语义升级为“DICOM + STL 逆向体素化映射”的二维校验视图。 +- 让医生或标注人员能在同一个二维视图中核对原始 CT 灰度影像和由 STL 构件层级推导出的 Label Map。 +- 保证构件层级面板中的颜色、透明度和显隐状态立即反映在右侧叠加层中。 +- 让右侧视图拥有独立于左侧 DICOM 范围控件的逐层切片浏览能力。 + +## 输入与输出 + +- 输入: + - 默认项目的 DICOM 切片预览接口 `/api/projects/:projectId/dicom-preview` + - STL preview 接口 `/api/projects/:projectId/models/:fileName/preview` + - 项目级 `moduleStyles` + - 用户选择的独立映射切片序号 +- 输出: + - 右侧“逆向分割映射视图” + - DICOM Base Layer + - STL 逆向投影 Overlay Label Map + - 与构件层级联动的颜色、透明度、显示隐藏状态 + - 独立 Slice Navigator + +## 影响范围 + +- 主要影响 `WebSite/src/components/ReverseWorkspace.tsx`。 +- 可能需要补充 `WebSite/src/index.css` 的滑条样式。 +- 不改变后端 API、不改变项目状态结构、不改变导出格式。 +- 文档会新增本次需求、实现、测试方案,并更新经验记录。 + +## 关键约束 + +- 历史经验要求不要伪造无来源 Mask。本次覆盖层必须从 STL preview 几何数据推导,且每个覆盖区域对应具体 STL 构件与 `partId`。 +- DICOM 原始影像必须作为底层常驻显示。 +- Overlay 可视属性必须直接读取 `moduleStyles`,避免右侧另建一套独立状态。 +- Slice Navigator 必须独立于左侧 DICOM 切片范围,不应破坏左侧三维融合和模型切分流程。 +- 当前工作区已有历史工程分析文档删除状态,本次提交不能混入这些删除。 + +## 风险点 + +- 当前后端尚未提供医学级真实体素化 Label Map,本次前端只能基于 STL preview 三角面做浏览器侧逆向投影映射,精度取决于 STL 预览采样与配准状态。 +- 高频拖动 Slice Navigator 会触发 DICOM 预览请求,需要防止旧请求覆盖新结果。 +- Canvas 图层尺寸必须保持一致,否则 DICOM 和 Overlay 会错位。 +- 构件数量和 STL 采样量较大时,Overlay 绘制可能有性能压力。 + +## 默认假设 + +- 本次按已有确认执行,不在方案阶段等待二次确认。 +- 右侧视图标题采用“逆向分割映射视图”,在界面中辅以 Label Map、Slice Navigator 等短标签。