2026-05-20-00-19-47 同步位姿并填充实体映射

This commit is contained in:
2026-05-20 00:30:25 +08:00
parent 2e04e2d5f9
commit 5cf1b20d2f
5 changed files with 620 additions and 63 deletions

View File

@@ -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<string, ModelPre
return hasBounds ? bounds : null;
}
function getModelSceneMetrics(files: string[], previews: Record<string, ModelPreviewPayload>): 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<string, ModelPreviewPayload>,
moduleStyles: Record<string, ModuleStyle>,
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<string, ModuleStyle>;
modelPose: ModelPose;
detailLimit: number;
slice: number;
totalSlices: number;
@@ -988,7 +1322,7 @@ function VoxelizationMappingView({
const [modelPreviews, setModelPreviews] = useState<Record<string, ModelPreviewPayload>>({});
const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
const [overlayStats, setOverlayStats] = useState({ activeModules: 0, paintedTriangles: 0 });
const [overlayStats, setOverlayStats] = useState<OverlayStats>({ 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 frame = window.requestAnimationFrame(() => {
const stats = drawVoxelOverlayLayer(
canvas,
dicomPreview,
stlFiles,
modelPreviews,
moduleStyles,
modelPose,
safeSlice,
Math.max(totalSlices, 1),
);
setOverlayStats(stats);
}, [dicomPreview, stlFiles.join('|'), modelPreviews, JSON.stringify(moduleStyles), safeSlice, totalSlices]);
});
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 ? (
<div className="absolute inset-0 flex items-center justify-center">
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain mix-blend-screen" />
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center px-8 text-center text-xs font-bold text-white/40">
@@ -1113,7 +1466,7 @@ function VoxelizationMappingView({
<div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-white/70">
<span className="truncate">{overlayStatus}</span>
<span className="font-mono text-cyan-100">
{overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.paintedTriangles}
{overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.segmentCount} · {overlayStats.filledPixels} px
</span>
</div>
<div className="grid max-h-20 grid-cols-1 gap-1 overflow-auto pr-1">
@@ -1835,6 +2188,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
<VoxelizationMappingView
project={project}
moduleStyles={moduleStyles}
modelPose={modelPose}
detailLimit={selectedDisplay.limit}
slice={safeMappingSlice}
totalSlices={project?.dicomCount ?? 0}

View File

@@ -0,0 +1,54 @@
# 实现方案-2026-05-20-00-19-47
## 实现方案文档路径
`工程分析/实现方案-2026-05-20-00-19-47.md`
## 修改目标
让右侧“逆向分割映射视图”的 Overlay Label Map 实时响应中部模型位姿,并将渲染形态从表面三角面投影升级为闭合实体 Mask 区域填充。
## 涉及路径
- `WebSite/src/components/ReverseWorkspace.tsx`
- `工程分析/需求分析-2026-05-20-00-19-47.md`
- `工程分析/实现方案-2026-05-20-00-19-47.md`
- `工程分析/测试方案-2026-05-20-00-19-47.md`
- `工程分析/经验记录.md`
## 技术路线
1.`VoxelizationMappingView` 新增 `modelPose` 入参,父组件直接传入中部工具栏当前位姿。
2. 新增浏览器端刚性变换函数:
- 以全局 STL bounds 中心归一化顶点。
-`scale -> 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 临时凭据方式推送。

View File

@@ -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 NavigatorDICOM 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 所需数据可正常返回。

View File

@@ -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`。涉及透明叠加的渲染改动,必须检查“前一层是否会被透明像素擦除”。

View File

@@ -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。