2026-05-20-00-19-47 同步位姿并填充实体映射
This commit is contained in:
@@ -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}
|
||||
|
||||
54
工程分析/实现方案-2026-05-20-00-19-47.md
Normal file
54
工程分析/实现方案-2026-05-20-00-19-47.md
Normal 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 临时凭据方式推送。
|
||||
59
工程分析/测试方案-2026-05-20-00-19-47.md
Normal file
59
工程分析/测试方案-2026-05-20-00-19-47.md
Normal 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 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 所需数据可正常返回。
|
||||
36
工程分析/经验记录.md
36
工程分析/经验记录.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`。涉及透明叠加的渲染改动,必须检查“前一层是否会被透明像素擦除”。
|
||||
|
||||
54
工程分析/需求分析-2026-05-20-00-19-47.md
Normal file
54
工程分析/需求分析-2026-05-20-00-19-47.md
Normal 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。
|
||||
Reference in New Issue
Block a user