diff --git a/Docker部署/README.md b/Docker部署/README.md index 7211238..5603dcc 100644 --- a/Docker部署/README.md +++ b/Docker部署/README.md @@ -12,14 +12,14 @@ 当前 Docker 构建会同步包含以下能力: -- 二维逆向分割映射按实体填充显示,导出的分割 Label Map 也按填充区域写入。 +- 二维逆向分割映射按实体填充显示,rib、skin 等薄壳或细长构件会做局部实体化兜底,导出的分割 Label Map 也按填充区域写入。 - 模型位姿支持以模型中心沿 X/Y/Z 轴镜像翻转,保存、项目库预览和导出均沿用该位姿。 - “构件分别导出”会把所有构件 NIfTI 文件集中到导出包内的 `segmentation-parts/` 目录。 - 项目库 DICOM 首页支持滚轮缩放、拖拽平移和位置重置。 - 项目库与工作区的 DICOM 切片编号按医学影像顺序显示,滑条使用非进度条样式。 - 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。 - 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。 -- 逆向分割映射视图按当前可见构件加载高精度 STL 预览;“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。 +- 逆向分割映射视图按当前可见构件加载高精度 STL 预览;实体模式最高使用 80 万三角面预览,“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。 ## 一、本机部署 @@ -145,3 +145,4 @@ npm run serve -- --host 0.0.0.0 --port 4000 - 逆向工作区切换构件层级时,可见 STL 构件会显示加载进度,避免高精度预览加载期间误判为构件不显示。 - DICOM 与逆向分割映射画布的滚轮缩放使用非被动 wheel 监听,修正浏览器控制台 `Unable to preventDefault inside passive event listener invocation` 警告。 +- rib、skin 等薄壳或细长构件在实体模式和逆向分割映射视图中使用更高精度 STL 预览,并增加局部厚度填充,避免只显示散点或单像素线段。 diff --git a/WebSite/server.ts b/WebSite/server.ts index bc408d7..4270d1d 100644 --- a/WebSite/server.ts +++ b/WebSite/server.ts @@ -142,7 +142,7 @@ const dicomVolumeCache = new Map(); const modelPreviewCache = new Map(); const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899']; -const maxPreviewTriangles = 500000; +const maxPreviewTriangles = 800000; const defaultModelPose: ModelPoseValue = { rotateX: 0, rotateY: 0, @@ -1288,7 +1288,76 @@ function closeExportMaskGaps(mask: Uint8Array, width: number, height: number, ma return toFill.size; } -function fillExportRows(data: Buffer, width: number, height: number, slice: number, rows: number[][], label: number) { +function exportSolidStrokeRadius(width: number, height: number) { + return Math.max(2.2, Math.min(5.5, Math.max(width, height) * 0.006)); +} + +function paintExportMaskPixel(mask: Uint8Array, width: number, height: number, x: number, y: number) { + if (x < 0 || x >= width || y < 0 || y >= height) { + return 0; + } + + const index = y * width + x; + if (mask[index]) { + return 0; + } + + mask[index] = 1; + return 1; +} + +function fillExportSegmentCapsules( + mask: Uint8Array, + width: number, + height: number, + segments: PlaneSegmentRecord[], + radius: number, +) { + let paintedPixels = 0; + const radiusSquared = radius * radius; + + segments.forEach(({ a, b }) => { + if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) { + return; + } + + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSquared = dx * dx + dy * dy; + const minX = clampNumber(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1); + const maxX = clampNumber(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1); + const minY = clampNumber(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1); + const maxY = clampNumber(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1); + + for (let y = minY; y <= maxY; y += 1) { + for (let x = minX; x <= maxX; x += 1) { + const px = x + 0.5; + const py = y + 0.5; + const t = lengthSquared <= 1e-6 + ? 0 + : clampNumber(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1); + const closestX = a.x + dx * t; + const closestY = a.y + dy * t; + const distanceSquared = (px - closestX) ** 2 + (py - closestY) ** 2; + if (distanceSquared <= radiusSquared) { + paintedPixels += paintExportMaskPixel(mask, width, height, x, y); + } + } + } + }); + + return paintedPixels; +} + +function fillExportRows( + data: Buffer, + width: number, + height: number, + slice: number, + rows: number[][], + label: number, + solidSegments: PlaneSegmentRecord[] = [], +) { const mask = new Uint8Array(width * height); let filledPixels = 0; @@ -1325,11 +1394,15 @@ function fillExportRows(data: Buffer, width: number, height: number, slice: numb } }); + if (solidSegments.length) { + filledPixels += fillExportSegmentCapsules(mask, width, height, solidSegments, exportSolidStrokeRadius(width, height)); + } + if (filledPixels === 0) { return 0; } - filledPixels += closeExportMaskGaps(mask, width, height); + filledPixels += closeExportMaskGaps(mask, width, height, 3); filledPixels += fillExportInternalHoles(mask, width, height); const sliceOffset = slice * width * height; for (let index = 0; index < mask.length; index += 1) { @@ -1527,11 +1600,11 @@ function createSegmentationData( }); slicesByIndex.forEach(({ segments }, slice) => { - groupExportSegmentsByConnectivity(segments).forEach((group) => { + groupExportSegmentsByConnectivity(segments, exportSolidStrokeRadius(volume.width, volume.height) * 1.15).forEach((group) => { const rows = Array.from({ length: volume.height }, () => [] as number[]); group.forEach((segment) => addExportSegmentToRows(rows, volume.width, volume.height, segment)); - const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label); - if (filledPixels < Math.max(12, Math.round(group.length * 0.45)) && group.length >= 3) { + const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label, group); + if (filledPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) { fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, group, label); } }); diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 92caee6..b4bbbac 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -91,7 +91,7 @@ const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number } { id: 'standard', label: '标准', limit: 16000 }, { id: 'fine', label: '精细', limit: 36000 }, { id: 'ultra', label: '超精细', limit: 72000 }, - { id: 'solid', label: '实体', limit: 200000 }, + { id: 'solid', label: '实体', limit: 800000 }, ]; const defaultModelPose: ModelPose = { rotateX: 0, @@ -703,22 +703,24 @@ function NativeStlViewer({ let loaded = 0; let failed = 0; const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = []; + const modelPreviewLimit = solidMode ? Math.max(detailLimit, 800000) : detailLimit; visibleFiles.forEach((fileName) => { - getCachedModelPreview(projectId, fileName, detailLimit) + getCachedModelPreview(projectId, fileName, modelPreviewLimit) .then((payload) => { if (disposed) return; const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3)); geometry.computeVertexNormals(); const style = styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 }; - const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity; + const materialOpacity = solidMode ? 1 : style.opacity; const mesh = new THREE.Mesh( geometry, new THREE.MeshStandardMaterial({ color: style.color, opacity: materialOpacity, transparent: materialOpacity < 1, + depthWrite: materialOpacity >= 1, roughness: solidMode ? 0.56 : 0.42, metalness: 0.04, side: THREE.DoubleSide, diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index b4c575f..ef480a6 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -70,7 +70,7 @@ export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: num { id: 'standard', label: '标准', limit: 16000 }, { id: 'fine', label: '精细', limit: 36000 }, { id: 'ultra', label: '超精细', limit: 72000 }, - { id: 'solid', label: '实体', limit: 200000 }, + { id: 'solid', label: '实体', limit: 800000 }, ]; export const dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOpacity: number; volumeOpacity: number; boxOpacity: number }> = [ { id: 'low', label: '低', sliceOpacity: 0.82, volumeOpacity: 0.12, boxOpacity: 0.32 }, @@ -707,13 +707,14 @@ export function FusionThreeView({ const stlFiles = project.stlFiles ?? []; const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false); + const modelPreviewLimit = solidMode ? Math.max(detailLimit, 800000) : detailLimit; let modelBaseScale = 1; let loadedModels = 0; let failedModels = 0; const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = []; Promise.allSettled(stlFiles.map((fileName, index) => ( - getCachedModelPreview(project.id, fileName, detailLimit) + getCachedModelPreview(project.id, fileName, modelPreviewLimit) .then((payload) => { if (disposed) return; const style = moduleStyles[fileName] ?? { @@ -732,11 +733,12 @@ export function FusionThreeView({ const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3)); geometry.computeVertexNormals(); - const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity; + const materialOpacity = solidMode ? 1 : style.opacity; const material = new THREE.MeshStandardMaterial({ color: style.color, - transparent: true, + transparent: materialOpacity < 1, opacity: materialOpacity, + depthWrite: materialOpacity >= 1, roughness: solidMode ? 0.56 : 0.48, metalness: 0.03, side: THREE.DoubleSide, @@ -1816,6 +1818,80 @@ function closeSmallMaskGaps( return toFill.size; } +function solidStrokeRadius(width: number, height: number) { + return Math.max(2.2, Math.min(5.5, Math.max(width, height) * 0.006)); +} + +function paintMaskPixel( + maskData: ImageData, + width: number, + height: number, + x: number, + y: number, + rgb: { r: number; g: number; b: number }, + alpha: number, +) { + if (x < 0 || x >= width || y < 0 || y >= height) { + return 0; + } + + const offset = (y * width + x) * 4; + if (maskData.data[offset + 3] > 0) { + return 0; + } + + maskData.data[offset] = rgb.r; + maskData.data[offset + 1] = rgb.g; + maskData.data[offset + 2] = rgb.b; + maskData.data[offset + 3] = alpha; + return 1; +} + +function fillSegmentCapsulesIntoMask( + maskData: ImageData, + width: number, + height: number, + segments: PlaneSegment[], + rgb: { r: number; g: number; b: number }, + alpha: number, + radius: number, +) { + let paintedPixels = 0; + const radiusSquared = radius * radius; + + segments.forEach(({ a, b }) => { + if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) { + return; + } + + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSquared = dx * dx + dy * dy; + const minX = clamp(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1); + const maxX = clamp(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1); + const minY = clamp(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1); + const maxY = clamp(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1); + + for (let y = minY; y <= maxY; y += 1) { + for (let x = minX; x <= maxX; x += 1) { + const px = x + 0.5; + const py = y + 0.5; + const t = lengthSquared <= 1e-6 + ? 0 + : clamp(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1); + const closestX = a.x + dx * t; + const closestY = a.y + dy * t; + const distanceSquared = (px - closestX) ** 2 + (py - closestY) ** 2; + if (distanceSquared <= radiusSquared) { + paintedPixels += paintMaskPixel(maskData, width, height, x, y, rgb, alpha); + } + } + } + }); + + return paintedPixels; +} + function drawFallbackClosedRegion( context: CanvasRenderingContext2D, width: number, @@ -1912,7 +1988,8 @@ function fillSegmentsAsSolidMask( } const maskData = maskContext.createImageData(width, height); let filledPixels = 0; - const groups = groupPlaneSegmentsByConnectivity(segments); + const radius = solidStrokeRadius(width, height); + const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15); const fallbackGroups: PlaneSegment[][] = []; groups.forEach((group) => { @@ -1959,14 +2036,15 @@ function fillSegmentsAsSolidMask( } } }); + groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius); filledPixels += groupPixels; - if (groupPixels < Math.max(12, Math.round(group.length * 0.45)) && group.length >= 3) { + if (groupPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) { fallbackGroups.push(group); } }); - filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha); + filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3); filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha); maskContext.putImageData(maskData, 0, 0); context.drawImage(maskCanvas, 0, 0); @@ -2221,7 +2299,7 @@ export function VoxelizationMappingView({ let disposed = false; let loaded = 0; const total = visibleStlFiles.length; - const previewLimit = Math.max(detailLimit, 500000); + const previewLimit = Math.max(detailLimit, 800000); const updateLoadProgress = (phase: string) => { if (!disposed) { setOverlayLoadState({ loading: true, loaded, total, phase }); @@ -3396,7 +3474,7 @@ export default function ReverseWorkspace({ const fusionStart = Math.min(displayStart, displayEnd); const fusionEnd = Math.max(displayStart, displayEnd); const previewLimit = selectedDisplay.limit; - const mappingPreviewLimit = Math.max(previewLimit, 200000); + const mappingPreviewLimit = Math.max(previewLimit, 800000); const total = 2 + stlFilesForLoad.length * 2; const startedAt = Date.now(); let loaded = 0; diff --git a/工程分析/实现方案-2026-05-24-18-59-49.md b/工程分析/实现方案-2026-05-24-18-59-49.md new file mode 100644 index 0000000..0fe38e3 --- /dev/null +++ b/工程分析/实现方案-2026-05-24-18-59-49.md @@ -0,0 +1,56 @@ +# 实现方案-2026-05-24-18-59-49 + +## 实现方案文档路径 + +`工程分析/实现方案-2026-05-24-18-59-49.md` + +## 修改目标 + +- 修正 rib/skin 等薄壳或细长 STL 在逆向分割映射视图中的点状、线状显示。 +- 前端 overlay 与服务端导出共用更强的实体化兜底策略。 +- 部署后让当前项目重新使用新算法实时生成映射结果。 + +## 涉及路径 + +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/server.ts` +- `Docker部署/README.md` +- `工程分析/经验记录.md` + +## 技术路线 + +- 在已有连通组扫描线填充基础上增加“切面实体兜底”: + - 对每个构件、每个切片、每个连通组独立处理。 + - 正常闭合轮廓继续使用扫描线填充。 + - 对面积过小、仅点线、薄壳或断续线段的组,按线段周围生成局部厚度,并对端点/边界做圆形或方形膨胀。 + - 最后做小缝隙闭合与内部孔洞填充。 +- 同步服务端 NIfTI 导出,让下载结果与界面展示一致。 +- 保持当前高精度 STL preview 与加载进度逻辑。 + +## 执行步骤 + +1. 定位前端 overlay 填充和后端导出填充函数。 +2. 抽取或补充局部 thick stroke / dilation 兜底函数。 +3. 在前端 `drawVoxelOverlayLayer` 的每个连通组处理中应用实体兜底。 +4. 在后端导出 mask 生成中应用相同实体兜底。 +5. 执行类型检查、构建、部署和访问验证。 +6. 提交并推送到 Gitea。 + +## 兼容性与回滚方案 + +- 修改只影响显示与导出 mask 生成,不影响项目状态结构和上传数据。 +- 如发现膨胀过强,可通过减小兜底半径回滚到更保守的实体化。 +- 回滚可直接撤销本次 commit 并重新部署。 + +## 预计文件变更 + +- 2 个核心代码文件。 +- 1 个 Docker 部署说明文件。 +- 3 个工程分析当次文档。 +- 1 个经验记录追加。 + +## 提交与部署策略 + +- Commit message 使用 `2026-05-24-18-59-49 修正薄壳构件实体化映射`。 +- 通过 `npm run lint` 与 `npm run build` 后重启 `tmux` 会话 `revoxelseg-dicom`。 +- 验证本机与公网入口可访问。 diff --git a/工程分析/测试方案-2026-05-24-18-59-49.md b/工程分析/测试方案-2026-05-24-18-59-49.md new file mode 100644 index 0000000..bed2b36 --- /dev/null +++ b/工程分析/测试方案-2026-05-24-18-59-49.md @@ -0,0 +1,45 @@ +# 测试方案-2026-05-24-18-59-49 + +## 测试方案文档路径 + +`工程分析/测试方案-2026-05-24-18-59-49.md` + +## 静态检查 + +- 执行 `cd WebSite && npm run lint`。 +- 搜索新增实体化函数调用,确认前端显示与后端导出均覆盖。 + +## 构建检查 + +- 执行 `cd WebSite && npm run build`。 +- 确认生产构建成功。 + +## 关键业务场景验证 + +- 当前项目在逆向分割映射视图中显示 rib、skin、肝脏等构件时,薄构件不再只剩散点或单像素线段。 +- 切换构件可见性后,加载进度条仍正常显示。 +- 肝脏等大实体构件仍保持实体填充,不被过度扩张。 + +## 医学影像数据相关边界验证 + +- 单构件内多个断开小区域保持独立处理,不跨构件混色。 +- thin shell/骨骼结构允许视觉上略厚于真实切面,但不能跨越远距离碎片形成大面积桥接。 +- 导出 NIfTI 的实体化策略与前端一致。 + +## 部署验证 + +- 重启 `tmux` 会话 `revoxelseg-dicom`。 +- 验证 `http://127.0.0.1:4000/api/health`。 +- 验证 `http://127.0.0.1:4000/`。 +- 验证 `https://revoxel.huijutec.cn/api/health` 与 `https://revoxel.huijutec.cn/`。 + +## Git/Gitea 备份验证 + +- 仅暂存本次相关代码和工程分析文档。 +- 提交 message 包含 `2026-05-24-18-59-49`。 +- 推送到 Gitea `main` 并确认本地分支与远端同步。 + +## 风险与回归关注点 + +- rib/skin 这类结构在医学真实切面中可能本来很薄,本次是为了满足用户的实体化显示需求。 +- 如果后续接入真实体素化算法,应把当前膨胀兜底作为可配置的显示/演示层,而不是临床级分割后处理。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 582b60e..b4edd68 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -1747,3 +1747,21 @@ C. 解决问题方案 D. 后续如何避免问题 后续只要某个 UI 操作会触发异步加载大 STL、DICOM 或导出预计算,都应提供明确加载状态、进度和空状态,避免用户把正常等待理解为显示故障。需要阻止滚轮默认滚动时,应使用原生非被动 wheel 监听并在卸载时清理;触摸按钮优先使用 pointer 事件和 `touch-action` 控制,不要在 passive-prone 的 touch 合成事件中调用 `preventDefault`。 + +## 2026-05-24-18-59-49 薄壳和细长构件需要局部实体化兜底 + +A. 具体问题 + +用户反馈当前项目中 `rib` 在三维融合视图里仍像散点,逆向分割映射视图右侧仍有一条一条的线;希望 `skin`、`rib` 等所有构件在映射视图中尽量显示为实心。 + +B. 产生问题原因 + +当前项目 `rib.stl` 有 524364 个三角面、`skin.stl` 有 2720412 个三角面。此前实体模式预览上限为 20 万,二维映射高精度上限为 50 万;由于预览抽样使用 `ceil(triangleCount / limit)` 计算步长,`rib` 在 50 万上限下仍会变成每隔一个三角面取样,细长结构更容易断成点线。另一个原因是二维填充主要依赖扫描线交点配对,近水平切线会被跳过;当 thin shell 或肋骨切面不能形成完整闭合轮廓时,就只剩线段描边。 + +C. 解决问题方案 + +将实体模式和逆向分割映射预览上限提高到 80 万,服务端 STL preview 上限同步提高到 80 万,使 `rib` 能全量进入实体预览,`skin` 也能获得更高密度抽样。三维实体模式材质改为不透明并写入深度,减少透明材质排序造成的散点观感。前端 overlay 和服务端 NIfTI 导出在扫描线填充之外,对每个连通组的原始切面线段绘制局部 capsule 厚度,再做小缝隙闭合和内部孔洞填充,保证薄壳、细长构件也有实体化兜底。 + +D. 后续如何避免问题 + +后续遇到薄壳、肋骨、皮肤、血管等结构显示成点线时,要同时检查三件事:STL preview 是否因上限导致抽样缺面、二维切面算法是否跳过水平/切线段、透明材质是否造成三维自排序噪声。实体化显示和导出必须同步处理,不能只改 UI;但局部厚度兜底属于演示/显示后处理,接入真实体素化算法后应作为可配置显示选项,而不是替代真实分割算法。 diff --git a/工程分析/需求分析-2026-05-24-18-59-49.md b/工程分析/需求分析-2026-05-24-18-59-49.md new file mode 100644 index 0000000..eb017e9 --- /dev/null +++ b/工程分析/需求分析-2026-05-24-18-59-49.md @@ -0,0 +1,44 @@ +# 需求分析-2026-05-24-18-59-49 + +## 开始时间 + +2026-05-24-18-59-49 + +## 原始需求摘要 + +用户反馈 `rib` 在三维融合视图中仍像散点,逆向分割映射视图右侧仍有一条一条的显示效果;希望包括 `skin`、`rib` 在内的所有构件在“逆向分割映射视图”中都尽可能显示为实心区域。用户认为此前若已修改过,应重新处理当前项目。 + +## 业务目标 + +- 让薄壳、细长、稀疏切面构件在二维逆向分割映射中有实体填充兜底。 +- 减少 rib/skin 等构件在切面上仅显示散点、短线或断续线段的视觉问题。 +- 保持已有肝脏等大实体构件的填充效果,不引入跨构件或跨断开区域的大面积误连。 + +## 输入与输出 + +- 输入:当前项目 STL 构件、DICOM 切片、模型位姿、构件可见状态。 +- 输出:逆向分割映射视图中,所有可见构件的切面尽量以填充实体呈现;对 thin shell/骨骼类断续切面增加厚度和闭合兜底。 + +## 影响范围 + +- `WebSite/src/components/ReverseWorkspace.tsx`:前端二维 overlay 切面填充与兜底显示。 +- `WebSite/server.ts`:NIfTI 导出算法若与前端共享相同实体化策略,需要同步修正。 +- `Docker部署/README.md`:同步说明实体化兜底能力。 +- `工程分析/经验记录.md`:沉淀薄壳/细长结构实体化经验。 + +## 关键约束 + +- 不能修改原始 DICOM/STL 文件。 +- 不能把不同构件混成一个标签;填充只能在单构件内部处理。 +- 当前工程仍是演示闭环,修改应明确为视觉和演示导出层面的实体化兜底,不声称真实临床算法。 + +## 风险点 + +- rib/skin 这类结构天然可能是薄壳或细长带状结构,过度膨胀会让其比真实切面更厚。 +- 若直接全局连接所有点,可能重新产生之前的长桥和错误相连。 +- 导出算法与前端显示不一致,会让用户看到实体但下载后仍是线。 + +## 待确认问题或默认假设 + +- 默认按用户要求将所有构件在二维映射中做“实体化显示兜底”,优先视觉和导出演示一致性。 +- 对 rib/skin 这类薄结构采用按连通组局部膨胀和闭合,不跨构件、不跨明显远距离碎片。