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 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 ? (
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user