diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index 545d6b0..580148d 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -799,6 +799,34 @@ interface ModelBounds { max: { x: number; y: number; z: number }; } +interface Point2D { + x: number; + y: number; +} + +interface Point3D { + x: number; + y: number; + z: number; +} + +interface ModelSceneMetrics { + center: Point3D; + normalizer: number; + viewExtent: number; +} + +interface PlaneSegment { + a: Point2D; + b: Point2D; +} + +interface OverlayStats { + activeModules: number; + filledPixels: number; + segmentCount: number; +} + function getPayloadBounds(payload: ModelPreviewPayload): ModelBounds | null { if (payload.bounds) { return payload.bounds; @@ -852,6 +880,312 @@ function getGlobalModelBounds(files: string[], previews: Record): ModelSceneMetrics | null { + const globalBounds = getGlobalModelBounds(files, previews); + if (!globalBounds) { + return null; + } + + 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 maxSpan = Math.max(spanX, spanY, spanZ, 0.001); + + return { + center: { + x: (globalBounds.min.x + globalBounds.max.x) / 2, + y: (globalBounds.min.y + globalBounds.max.y) / 2, + z: (globalBounds.min.z + globalBounds.max.z) / 2, + }, + normalizer: 2 / maxSpan, + viewExtent: 2.9, + }; +} + +function transformPointForPose(x: number, y: number, z: number, metrics: ModelSceneMetrics, pose: ModelPose): Point3D { + let px = (x - metrics.center.x) * metrics.normalizer * pose.scale; + let py = (y - metrics.center.y) * metrics.normalizer * pose.scale; + let pz = (z - metrics.center.z) * metrics.normalizer * pose.scale; + + const rotateX = THREE.MathUtils.degToRad(pose.rotateX); + const rotateY = THREE.MathUtils.degToRad(pose.rotateY); + const rotateZ = THREE.MathUtils.degToRad(pose.rotateZ); + const cosX = Math.cos(rotateX); + const sinX = Math.sin(rotateX); + const cosY = Math.cos(rotateY); + const sinY = Math.sin(rotateY); + const cosZ = Math.cos(rotateZ); + const sinZ = Math.sin(rotateZ); + + const afterX = { + x: px, + y: py * cosX - pz * sinX, + z: py * sinX + pz * cosX, + }; + const afterY = { + x: afterX.x * cosY + afterX.z * sinY, + y: afterX.y, + z: -afterX.x * sinY + afterX.z * cosY, + }; + px = afterY.x * cosZ - afterY.y * sinZ; + py = afterY.x * sinZ + afterY.y * cosZ; + pz = afterY.z; + + return { + x: px + pose.translateX, + y: py + pose.translateY, + z: pz + pose.translateZ, + }; +} + +function intersectEdgeWithPlane(start: Point3D, end: Point3D, targetZ: number): Point2D | null { + const epsilon = 1e-5; + const startDistance = start.z - targetZ; + const endDistance = end.z - targetZ; + + if (Math.abs(startDistance) <= epsilon && Math.abs(endDistance) <= epsilon) { + return null; + } + + if (Math.abs(startDistance) <= epsilon) { + return { x: start.x, y: start.y }; + } + + if (Math.abs(endDistance) <= epsilon) { + return { x: end.x, y: end.y }; + } + + if ((startDistance > 0 && endDistance > 0) || (startDistance < 0 && endDistance < 0)) { + return null; + } + + const t = startDistance / (startDistance - endDistance); + return { + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t, + }; +} + +function pointDistanceSquared(a: Point2D, b: Point2D) { + const dx = a.x - b.x; + const dy = a.y - b.y; + return dx * dx + dy * dy; +} + +function intersectTriangleWithPlane(a: Point3D, b: Point3D, c: Point3D, targetZ: number): PlaneSegment | null { + const intersections = [ + intersectEdgeWithPlane(a, b, targetZ), + intersectEdgeWithPlane(b, c, targetZ), + intersectEdgeWithPlane(c, a, targetZ), + ].filter((point): point is Point2D => Boolean(point)); + + const uniquePoints: Point2D[] = []; + intersections.forEach((point) => { + const exists = uniquePoints.some((current) => pointDistanceSquared(current, point) < 1e-8); + if (!exists) { + uniquePoints.push(point); + } + }); + + if (uniquePoints.length < 2) { + return null; + } + + let segment: PlaneSegment = { a: uniquePoints[0], b: uniquePoints[1] }; + let maxDistance = pointDistanceSquared(segment.a, segment.b); + for (let first = 0; first < uniquePoints.length; first += 1) { + for (let second = first + 1; second < uniquePoints.length; second += 1) { + const distance = pointDistanceSquared(uniquePoints[first], uniquePoints[second]); + if (distance > maxDistance) { + maxDistance = distance; + segment = { a: uniquePoints[first], b: uniquePoints[second] }; + } + } + } + + return maxDistance > 1e-8 ? segment : null; +} + +function parseHexColor(color: string) { + const normalized = color.replace('#', '').trim(); + const value = normalized.length === 3 + ? normalized.split('').map((item) => item + item).join('') + : normalized.padEnd(6, '0').slice(0, 6); + const parsed = Number.parseInt(value, 16); + + if (!Number.isFinite(parsed)) { + return { r: 59, g: 130, b: 246 }; + } + + return { + r: (parsed >> 16) & 255, + g: (parsed >> 8) & 255, + b: parsed & 255, + }; +} + +function drawFallbackClosedRegion( + context: CanvasRenderingContext2D, + width: number, + height: number, + segments: PlaneSegment[], + color: string, + opacity: number, +) { + const points = segments.flatMap((segment) => [segment.a, segment.b]) + .filter((point) => ( + Number.isFinite(point.x) + && Number.isFinite(point.y) + && point.x >= -width + && point.x <= width * 2 + && point.y >= -height + && point.y <= height * 2 + )); + + if (points.length < 3) { + return 0; + } + + const center = points.reduce((accumulator, point) => ({ + x: accumulator.x + point.x / points.length, + y: accumulator.y + point.y / points.length, + }), { x: 0, y: 0 }); + const ordered = [...points].sort((left, right) => ( + Math.atan2(left.y - center.y, left.x - center.x) - Math.atan2(right.y - center.y, right.x - center.x) + )); + + context.save(); + context.globalAlpha = clamp(opacity, 0.1, 1) * 0.48; + context.fillStyle = color; + context.beginPath(); + ordered.forEach((point, index) => { + if (index === 0) { + context.moveTo(point.x, point.y); + return; + } + context.lineTo(point.x, point.y); + }); + context.closePath(); + context.fill(); + context.restore(); + + return Math.max(1, Math.round(points.length / 2)); +} + +function fillSegmentsAsSolidMask( + context: CanvasRenderingContext2D, + width: number, + height: number, + segments: PlaneSegment[], + color: string, + opacity: number, +) { + if (!segments.length) { + return 0; + } + + const rows: number[][] = Array.from({ length: height }, () => []); + segments.forEach((segment) => { + const { a, b } = segment; + if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) { + return; + } + + const deltaY = b.y - a.y; + if (Math.abs(deltaY) < 0.01) { + return; + } + + const minY = Math.max(0, Math.floor(Math.min(a.y, b.y))); + const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y))); + for (let row = minY; row <= maxY; row += 1) { + const sampleY = row + 0.5; + const crosses = (sampleY >= a.y && sampleY < b.y) || (sampleY >= b.y && sampleY < a.y); + if (!crosses) { + continue; + } + + const t = (sampleY - a.y) / deltaY; + const x = a.x + (b.x - a.x) * t; + if (Number.isFinite(x)) { + rows[row].push(x); + } + } + }); + + const rgb = parseHexColor(color); + const alpha = Math.round(clamp(opacity, 0.1, 1) * 190); + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = width; + maskCanvas.height = height; + const maskContext = maskCanvas.getContext('2d'); + if (!maskContext) { + return 0; + } + const maskData = maskContext.createImageData(width, height); + let filledPixels = 0; + + rows.forEach((intersections, row) => { + if (intersections.length < 2) { + return; + } + + intersections.sort((left, right) => left - right); + const cleaned: number[] = []; + intersections.forEach((x) => { + const previous = cleaned[cleaned.length - 1]; + if (previous === undefined || Math.abs(previous - x) > 0.35) { + cleaned.push(x); + } + }); + + for (let index = 0; index + 1 < cleaned.length; index += 2) { + const rawStartX = cleaned[index]; + const rawEndX = cleaned[index + 1]; + if (rawEndX < 0 || rawStartX > width - 1) { + continue; + } + + const startX = clamp(Math.ceil(rawStartX), 0, width - 1); + const endX = clamp(Math.floor(rawEndX), 0, width - 1); + if (endX < startX) { + continue; + } + + for (let x = startX; x <= endX; x += 1) { + const offset = (row * width + x) * 4; + maskData.data[offset] = rgb.r; + maskData.data[offset + 1] = rgb.g; + maskData.data[offset + 2] = rgb.b; + maskData.data[offset + 3] = alpha; + filledPixels += 1; + } + } + }); + + maskContext.putImageData(maskData, 0, 0); + context.drawImage(maskCanvas, 0, 0); + if (filledPixels === 0 && segments.length >= 3) { + filledPixels = drawFallbackClosedRegion(context, width, height, segments, color, opacity); + } + + context.save(); + context.globalAlpha = clamp(opacity, 0.1, 1) * 0.82; + context.strokeStyle = color; + context.lineWidth = Math.max(1.2, Math.max(width, height) * 0.003); + context.lineCap = 'round'; + context.lineJoin = 'round'; + context.beginPath(); + segments.forEach((segment) => { + context.moveTo(segment.a.x, segment.a.y); + context.lineTo(segment.b.x, segment.b.y); + }); + context.stroke(); + context.restore(); + + return filledPixels; +} + function drawDicomBaseLayer(canvas: HTMLCanvasElement, preview: DicomPreview) { canvas.width = preview.width; canvas.height = preview.height; @@ -879,41 +1213,33 @@ function drawVoxelOverlayLayer( files: string[], previews: Record, moduleStyles: Record, + modelPose: ModelPose, slice: number, totalSlices: number, -) { +): OverlayStats { canvas.width = preview.width; canvas.height = preview.height; const context = canvas.getContext('2d'); if (!context) { - return { activeModules: 0, paintedTriangles: 0 }; + return { activeModules: 0, filledPixels: 0, segmentCount: 0 }; } context.clearRect(0, 0, preview.width, preview.height); - const globalBounds = getGlobalModelBounds(files, previews); - if (!globalBounds) { - return { activeModules: 0, paintedTriangles: 0 }; + const metrics = getModelSceneMetrics(files, previews); + if (!metrics) { + return { activeModules: 0, filledPixels: 0, segmentCount: 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; + const targetZ = -1 + normalizedSlice * 2; + const canvasScale = Math.min(preview.width, preview.height) / (metrics.viewExtent * 2); + const mapPoint = (point: Point2D): Point2D => ({ + x: preview.width / 2 + point.x * canvasScale, + y: preview.height / 2 - point.y * canvasScale, + }); let activeModules = 0; - let paintedTriangles = 0; - - context.save(); - context.lineJoin = 'round'; - context.lineCap = 'round'; - context.globalCompositeOperation = 'source-over'; + let filledPixels = 0; + let segmentCount = 0; files.forEach((fileName, index) => { const payload = previews[fileName]; @@ -928,48 +1254,55 @@ function drawVoxelOverlayLayer( 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; + const segments: PlaneSegment[] = []; 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); + const a = transformPointForPose( + payload.vertices[vertexIndex], + payload.vertices[vertexIndex + 1], + payload.vertices[vertexIndex + 2], + metrics, + modelPose, + ); + const b = transformPointForPose( + payload.vertices[vertexIndex + 3], + payload.vertices[vertexIndex + 4], + payload.vertices[vertexIndex + 5], + metrics, + modelPose, + ); + const c = transformPointForPose( + payload.vertices[vertexIndex + 6], + payload.vertices[vertexIndex + 7], + payload.vertices[vertexIndex + 8], + metrics, + modelPose, + ); + const segment = intersectTriangleWithPlane(a, b, c, targetZ); - if (minZ > targetZ + sliceBand || maxZ < targetZ - sliceBand) { - continue; + if (segment) { + segments.push({ + a: mapPoint(segment.a), + b: mapPoint(segment.b), + }); } - - 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) { + const modulePixels = fillSegmentsAsSolidMask(context, preview.width, preview.height, segments, style.color, style.opacity); + if (segments.length > 0) { activeModules += 1; } + filledPixels += modulePixels; + segmentCount += segments.length; }); - context.restore(); - return { activeModules, paintedTriangles }; + return { activeModules, filledPixels, segmentCount }; } function VoxelizationMappingView({ project, moduleStyles, + modelPose, detailLimit, slice, totalSlices, @@ -977,6 +1310,7 @@ function VoxelizationMappingView({ }: { project: Project | null; moduleStyles: Record; + modelPose: ModelPose; detailLimit: number; slice: number; totalSlices: number; @@ -988,7 +1322,7 @@ function VoxelizationMappingView({ const [modelPreviews, setModelPreviews] = useState>({}); const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片'); const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射'); - const [overlayStats, setOverlayStats] = useState({ activeModules: 0, paintedTriangles: 0 }); + const [overlayStats, setOverlayStats] = useState({ activeModules: 0, filledPixels: 0, segmentCount: 0 }); const maxSlice = Math.max(totalSlices - 1, 0); const safeSlice = clamp(slice, 0, maxSlice); const stlFiles = project?.stlFiles ?? []; @@ -1068,17 +1402,36 @@ function VoxelizationMappingView({ 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 frame = window.requestAnimationFrame(() => { + const stats = drawVoxelOverlayLayer( + canvas, + dicomPreview, + stlFiles, + modelPreviews, + moduleStyles, + modelPose, + safeSlice, + Math.max(totalSlices, 1), + ); + setOverlayStats(stats); + }); + + return () => window.cancelAnimationFrame(frame); + }, [ + dicomPreview, + stlFiles.join('|'), + modelPreviews, + JSON.stringify(moduleStyles), + modelPose.rotateX, + modelPose.rotateY, + modelPose.rotateZ, + modelPose.translateX, + modelPose.translateY, + modelPose.translateZ, + modelPose.scale, + safeSlice, + totalSlices, + ]); const stepSlice = (delta: number) => { onSliceChange(clamp(safeSlice + delta, 0, maxSlice)); @@ -1102,7 +1455,7 @@ function VoxelizationMappingView({ {dicomPreview ? (
- +
) : (
@@ -1113,7 +1466,7 @@ function VoxelizationMappingView({
{overlayStatus} - {overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.paintedTriangles} 面 + {overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
@@ -1835,6 +2188,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { rotateX -> rotateY -> rotateZ -> translate` 顺序变换。 + - 将变换后坐标映射到固定 DICOM Overlay 画布坐标系。 +3. 将原先“靠近切片的三角面填充”改为“切片平面求交”: + - 对每个三角面与当前 Z 平面求交。 + - 收集切面边界线段。 +4. 将边界线段按扫描线射线法光栅化为实心 Mask: + - 逐行收集边界线段与水平扫描线的交点。 + - 采用奇偶配对填充闭合区域内部像素。 + - 按构件颜色与透明度写入 Overlay ImageData,并通过离屏画布合成,避免多构件互相覆盖。 +5. 对边界不完全闭合的极少数抽样场景,使用端点多边形保底填充,避免完全空白。 +6. 使用 `requestAnimationFrame` 合并高频位姿变化,做到位姿滑条拖动时实时刷新。 +7. 更新右侧状态文案,显示实心 Mask 像素数量和参与构件数量。 + +## 兼容性与回滚方案 + +- 不修改后端 API,不影响 DICOM preview、STL preview 和导出接口。 +- 如果新填充算法异常,可回退到上一版 `drawVoxelOverlayLayer`。 +- 如果某个构件没有形成闭合区域,只影响该构件 Overlay,不影响 Base Layer 和其他构件。 + +## 预计文件变更 + +- `ReverseWorkspace.tsx`:新增位姿变换、切面求交、Mask flood fill、Overlay rAF 重绘。 +- 新增本次三份工程文档。 +- 更新 `经验记录.md`。 + +## 提交与部署策略 + +- 执行 `npm run lint` 与 `npm run build`。 +- 重新部署 `tmux` 会话 `revoxelseg-dicom`。 +- 提交信息:`2026-05-20-00-19-47 同步位姿并填充实体映射` +- 使用已验证的 Gitea 临时凭据方式推送。 diff --git a/工程分析/测试方案-2026-05-20-00-19-47.md b/工程分析/测试方案-2026-05-20-00-19-47.md new file mode 100644 index 0000000..6da547b --- /dev/null +++ b/工程分析/测试方案-2026-05-20-00-19-47.md @@ -0,0 +1,59 @@ +# 测试方案-2026-05-20-00-19-47 + +## 测试方案文档路径 + +`工程分析/测试方案-2026-05-20-00-19-47.md` + +## 静态检查 + +- 在 `WebSite/` 下执行 `npm run lint`。 + +## 构建检查 + +- 在 `WebSite/` 下执行 `npm run build`。 + +## 关键业务场景验证 + +- 打开逆向工作区,确认右侧仍为“逆向分割映射视图”。 +- 拖动中部模型位姿的 X/Y/Z 平移滑条,右侧 Overlay 位置即时变化。 +- 拖动 X/Y/Z 旋转滑条,右侧 Overlay 截面形态即时变化。 +- 拖动缩放滑条,右侧 Overlay 大小即时变化。 +- 右侧 Overlay 应显示连续实心色块,而不是零散表面三角面/点云。 +- 调整构件颜色、透明度、显示隐藏后,右侧实心 Mask 即时联动。 +- 拖动右侧 Slice Navigator,DICOM Base Layer 与实心 Mask 共同切换。 + +## 医学影像数据相关边界验证 + +- STL preview 不可用时,Base Layer 仍显示 DICOM。 +- 构件交线无法闭合时,不应导致页面报错。 +- 切片序号需要 clamp 到合法范围。 +- 位姿拖动时不应重新请求 STL preview,只应重绘 Overlay。 + +## 部署验证 + +- 重启 `tmux` 会话 `revoxelseg-dicom`。 +- 验证: + - `curl http://127.0.0.1:4000/api/health` + - `curl -I http://127.0.0.1:4000/` + +## Git/Gitea 备份验证 + +- 显式暂存本次相关代码和文档。 +- 创建包含时间戳和描述的 commit。 +- 推送到 Gitea `origin/main`。 + +## 回归关注点 + +- 不影响左侧三维融合视图。 +- 不影响中部构件层级保存。 +- 不影响 `.nii` / `.nii.gz` 导出按钮。 + +## 实际执行结果 + +- `npm run lint`:通过。 +- `npm run build`:通过;Vite 保留既有 chunk 体积提示,不影响构建产物生成。 +- 部署:已重启 `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,"service":"revoxelseg-dicom"}`。 +- `curl -I http://127.0.0.1:4000/`:通过,返回 `HTTP/1.1 200 OK`。 +- `curl http://127.0.0.1:4000/api/projects/head-ct-demo`:通过,确认示例项目含 300 张 DICOM 与 9 个 STL 构件。 +- `curl` 验证 DICOM preview 与 STL preview 接口:通过,右侧 Base/Overlay 所需数据可正常返回。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 66cf146..782126d 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -883,3 +883,39 @@ C. 解决问题方案 D. 后续如何避免问题 新增影像浏览控件前先判断其控制对象是“单切片位置”还是“显示范围/切割范围”。单切片校验使用独立 slice 状态,范围切割使用起止端点状态,避免不同视图之间产生隐式联动。 + +## 2026-05-20-00-19-47 右侧映射视图必须复用中部模型位姿 + +A. 具体问题 + +右侧“逆向分割映射视图”虽然已经使用 DICOM Base Layer 和 STL Overlay,但 Overlay 计算没有接入中部“模型位姿”状态;用户拖动 X/Y/Z 平移或旋转后,左侧三维视图变化,右侧二维映射仍按旧 STL 原始坐标绘制。 + +B. 产生问题原因 + +上一版 `VoxelizationMappingView` 只接收 `moduleStyles`、`slice` 和 STL preview 数据,没有接收 `modelPose`;`drawVoxelOverlayLayer` 直接使用 STL 原始顶点和全局 bounds 计算投影,没有先执行与三维场景一致的归一化、旋转、缩放和平移。 + +C. 解决问题方案 + +为 `VoxelizationMappingView` 增加 `modelPose` 入参,由父组件直接传入中部工具栏的当前位姿;在 Overlay 计算前对 STL 顶点执行 `scale -> rotateX -> rotateY -> rotateZ -> translate`,再与当前 Z 轴 DICOM 切片平面求交,并使用 `requestAnimationFrame` 合并高频滑条更新。 + +D. 后续如何避免问题 + +凡是右侧二维映射、Mask、Label Map 或导出预览依赖 STL 空间位置时,都必须确认其输入状态包含当前权威位姿;不能让三维视图和二维视图各自维护坐标变换。新增视图前先列出共享状态:位姿、构件样式、切片位置和数据源。 + +## 2026-05-20-00-19-47 实体 Mask 填充不能覆盖多构件叠加 + +A. 具体问题 + +将截面交线转为实心 Mask 时,如果每个构件直接在主 Overlay canvas 上 `putImageData`,后一个构件的透明像素会覆盖掉前一个构件,导致多构件同时显示时可能只剩最后绘制的构件。 + +B. 产生问题原因 + +Canvas 的 `putImageData` 是像素替换操作,不是透明合成操作;即使 ImageData 中某些像素 alpha 为 0,也会把目标 canvas 对应位置写成透明,从而擦掉已有 Overlay。 + +C. 解决问题方案 + +每个构件先在离屏 canvas 中生成扫描线射线法填充的实心 Mask,再使用 `drawImage` 以 `source-over` 合成到主 Overlay canvas;边界描边也在合成后按构件颜色与透明度绘制,保留多构件叠加效果。 + +D. 后续如何避免问题 + +多层或多构件 Canvas 叠加时,优先使用离屏画布或单次合并后的 ImageData;只有在明确要替换整张图时才使用主 canvas 的 `putImageData`。涉及透明叠加的渲染改动,必须检查“前一层是否会被透明像素擦除”。 diff --git a/工程分析/需求分析-2026-05-20-00-19-47.md b/工程分析/需求分析-2026-05-20-00-19-47.md new file mode 100644 index 0000000..98eda6a --- /dev/null +++ b/工程分析/需求分析-2026-05-20-00-19-47.md @@ -0,0 +1,54 @@ +# 需求分析-2026-05-20-00-19-47 + +## 开始时间 + +2026-05-20-00-19-47 + +## 原始需求摘要 + +用户要求继续优化右侧“逆向分割映射视图”:一是让中部“位姿调整”中的 X/Y/Z 平移与旋转实时驱动右侧二维映射重算,实现三维刚性变换与二维切片结果所见即所得;二是将当前偏表面边界/点云式的 Overlay 映射升级为医学标准的闭合实体区域实心 Mask 色块填充。 + +## 业务目标 + +- 中部模型位姿调整后,右侧 Overlay Label Map 立即按新位姿刷新。 +- 右侧视图不再显示零散表面投影,而是显示连续、饱满、可透明叠加的闭合区域 Mask。 +- 保持 DICOM 原始灰度图作为 Base Layer。 +- 保持构件层级颜色、透明度、显隐、Label ID 与右侧 Overlay 实时联动。 + +## 输入与输出 + +- 输入: + - 当前 `modelPose` 中的旋转、平移、缩放参数。 + - STL preview 三角面顶点。 + - 当前右侧 `mappingSlice`。 + - 构件层级 `moduleStyles`。 + - DICOM preview 灰度像素。 +- 输出: + - 应用位姿矩阵后的 STL 切片交线。 + - 由交线闭合填充生成的二维实心 Label Map。 + - 与 DICOM Base Layer 对齐的 Overlay Mask。 + +## 影响范围 + +- `WebSite/src/components/ReverseWorkspace.tsx` +- 本次工程分析文档与 `工程分析/经验记录.md` +- 不修改后端 API、不引入新的依赖。 + +## 关键约束 + +- 右侧映射必须直接使用中部 `modelPose`,不能另建位姿状态。 +- 位姿变换需要作用于 STL 顶点后再参与切片平面求交。 +- 生成 Mask 时必须从 STL 几何交线推导,避免无来源伪 Mask。 +- 右侧 Slice Navigator 仍保持独立,不影响左侧 DICOM 范围。 +- 本次提交不能混入历史 `工程分析` 文档删除状态。 + +## 风险点 + +- 浏览器端基于 STL preview 抽样做切面栅格化,精度仍受抽样数量影响,不能等同于后端医学级体素化。 +- flood fill 对边界闭合质量敏感,需要加粗边界并提供保底闭合策略。 +- 位姿滑条高频更新时,需要避免每次都重新请求 STL,只应在内存中重算 Overlay。 + +## 默认假设 + +- 当前需求继续按已确认的“后续直接搞”执行。 +- 默认把右侧 Overlay 算法升级为浏览器端交线光栅化与闭合填充,后续可替换为后端真实体素化 Label Map。