2026-05-20-00-38-39 对齐FOV并强化网格截面填充

This commit is contained in:
2026-05-20 00:48:34 +08:00
parent 5cf1b20d2f
commit 3e6b1e0d9f
5 changed files with 385 additions and 49 deletions

View File

@@ -72,6 +72,7 @@ const defaultModelPose: ModelPose = {
};
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const fusionBaseExtent = 4.6;
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
@@ -244,7 +245,8 @@ function FusionThreeView({
setLoadProgress(42);
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
const stlFiles = project.stlFiles ?? [];
const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false);
let modelBaseScale = 1;
let loadedModels = 0;
let failedModels = 0;
@@ -258,35 +260,37 @@ function FusionThreeView({
})
.then((payload) => {
if (disposed) return;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
geometry.computeVertexNormals();
const style = moduleStyles[fileName] ?? {
visible: true,
color: moduleColors[index % moduleColors.length],
opacity: 0.72,
partId: index + 1,
};
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity;
const material = new THREE.MeshStandardMaterial({
color: style.color,
transparent: true,
opacity: materialOpacity,
roughness: solidMode ? 0.56 : 0.48,
metalness: 0.03,
side: THREE.DoubleSide,
clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [],
clipIntersection: false,
clipShadows: true,
});
const mesh = new THREE.Mesh(geometry, material);
modelPivot.add(mesh);
if (payload.bounds) {
loadedBounds.push({
min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z),
max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z),
});
}
if (style.visible !== false) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
geometry.computeVertexNormals();
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity;
const material = new THREE.MeshStandardMaterial({
color: style.color,
transparent: true,
opacity: materialOpacity,
roughness: solidMode ? 0.56 : 0.48,
metalness: 0.03,
side: THREE.DoubleSide,
clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [],
clipIntersection: false,
clipShadows: true,
});
const mesh = new THREE.Mesh(geometry, material);
modelPivot.add(mesh);
}
loadedModels += 1;
setLoadProgress(42 + Math.round(((loadedModels + failedModels) / Math.max(stlFiles.length, 1)) * 46));
})
@@ -323,7 +327,7 @@ function FusionThreeView({
modelPoseGroup.position.set(0, 0, 0);
modelPivot.position.set(0, 0, dicomDepth * 0.08);
setLoadProgress(100);
setStatus(stlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前项目没有 STL');
setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件');
});
const rootPose = {
@@ -812,8 +816,11 @@ interface Point3D {
interface ModelSceneMetrics {
center: Point3D;
normalizer: number;
viewExtent: number;
modelBaseScale: number;
modelPivotOffsetZ: number;
dicomWidth: number;
dicomHeight: number;
dicomDepth: number;
}
interface PlaneSegment {
@@ -880,7 +887,41 @@ function getGlobalModelBounds(files: string[], previews: Record<string, ModelPre
return hasBounds ? bounds : null;
}
function getModelSceneMetrics(files: string[], previews: Record<string, ModelPreviewPayload>): ModelSceneMetrics | null {
function getPreviewPhysicalSize(preview: DicomPreview) {
const columnSpacing = preview.spacing?.displayX ?? preview.spacing?.column ?? 1;
const rowSpacing = preview.spacing?.displayY ?? preview.spacing?.row ?? 1;
const width = preview.physicalSize?.width ?? preview.width * columnSpacing;
const height = preview.physicalSize?.height ?? preview.height * rowSpacing;
return {
width: Math.max(width, 0.001),
height: Math.max(height, 0.001),
columnSpacing: Math.max(columnSpacing, 0.001),
rowSpacing: Math.max(rowSpacing, 0.001),
sliceSpacing: Math.max(preview.spacing?.slice ?? 1, 0.001),
};
}
function getFovCanvasSize(preview: DicomPreview) {
const physical = getPreviewPhysicalSize(preview);
const unit = Math.max(0.001, Math.min(physical.columnSpacing, physical.rowSpacing));
const rawWidth = Math.max(1, Math.round(physical.width / unit));
const rawHeight = Math.max(1, Math.round(physical.height / unit));
const maxDimension = 960;
const scale = Math.min(1, maxDimension / Math.max(rawWidth, rawHeight));
return {
width: Math.max(1, Math.round(rawWidth * scale)),
height: Math.max(1, Math.round(rawHeight * scale)),
};
}
function getModelSceneMetrics(
files: string[],
previews: Record<string, ModelPreviewPayload>,
preview: DicomPreview,
totalSlices: number,
): ModelSceneMetrics | null {
const globalBounds = getGlobalModelBounds(files, previews);
if (!globalBounds) {
return null;
@@ -889,7 +930,14 @@ function getModelSceneMetrics(files: string[], previews: Record<string, ModelPre
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);
const maxModelSize = Math.max(spanX, spanY, spanZ, 1);
const physical = getPreviewPhysicalSize(preview);
const physicalDepth = Math.max(totalSlices, 1) * physical.sliceSpacing;
const maxPhysical = Math.max(physical.width, physical.height, physicalDepth, 1);
const dicomWidth = (physical.width / maxPhysical) * fusionBaseExtent;
const dicomHeight = (physical.height / maxPhysical) * fusionBaseExtent;
const dicomDepth = Math.max((physicalDepth / maxPhysical) * fusionBaseExtent, 0.18);
const modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
return {
center: {
@@ -897,15 +945,19 @@ function getModelSceneMetrics(files: string[], previews: Record<string, ModelPre
y: (globalBounds.min.y + globalBounds.max.y) / 2,
z: (globalBounds.min.z + globalBounds.max.z) / 2,
},
normalizer: 2 / maxSpan,
viewExtent: 2.9,
modelBaseScale,
modelPivotOffsetZ: dicomDepth * 0.08,
dicomWidth,
dicomHeight,
dicomDepth,
};
}
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 scalar = metrics.modelBaseScale * pose.scale;
let px = (x - metrics.center.x) * scalar;
let py = (y - metrics.center.y) * scalar;
let pz = (z - metrics.center.z + metrics.modelPivotOffsetZ) * scalar;
const rotateX = THREE.MathUtils.degToRad(pose.rotateX);
const rotateY = THREE.MathUtils.degToRad(pose.rotateY);
@@ -1024,6 +1076,67 @@ function parseHexColor(color: string) {
};
}
function fillInternalMaskHoles(
maskData: ImageData,
width: number,
height: number,
rgb: { r: number; g: number; b: number },
alpha: number,
) {
const outside = new Uint8Array(width * height);
const stack: number[] = [];
const pushIfEmpty = (x: number, y: number) => {
if (x < 0 || x >= width || y < 0 || y >= height) {
return;
}
const index = y * width + x;
if (outside[index] || maskData.data[index * 4 + 3] > 0) {
return;
}
outside[index] = 1;
stack.push(index);
};
for (let x = 0; x < width; x += 1) {
pushIfEmpty(x, 0);
pushIfEmpty(x, height - 1);
}
for (let y = 0; y < height; y += 1) {
pushIfEmpty(0, y);
pushIfEmpty(width - 1, y);
}
while (stack.length) {
const index = stack.pop();
if (index === undefined) {
continue;
}
const x = index % width;
const y = Math.floor(index / width);
pushIfEmpty(x + 1, y);
pushIfEmpty(x - 1, y);
pushIfEmpty(x, y + 1);
pushIfEmpty(x, y - 1);
}
let patchedPixels = 0;
for (let index = 0; index < outside.length; index += 1) {
const offset = index * 4;
if (!outside[index] && maskData.data[offset + 3] === 0) {
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
patchedPixels += 1;
}
}
return patchedPixels;
}
function drawFallbackClosedRegion(
context: CanvasRenderingContext2D,
width: number,
@@ -1163,6 +1276,7 @@ function fillSegmentsAsSolidMask(
}
});
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
maskContext.putImageData(maskData, 0, 0);
context.drawImage(maskCanvas, 0, 0);
if (filledPixels === 0 && segments.length >= 3) {
@@ -1187,22 +1301,27 @@ function fillSegmentsAsSolidMask(
}
function drawDicomBaseLayer(canvas: HTMLCanvasElement, preview: DicomPreview) {
canvas.width = preview.width;
canvas.height = preview.height;
const fovCanvas = getFovCanvasSize(preview);
canvas.width = fovCanvas.width;
canvas.height = fovCanvas.height;
const context = canvas.getContext('2d');
if (!context) {
return;
}
const binary = atob(preview.pixels);
const imageData = context.createImageData(preview.width, preview.height);
for (let index = 0; index < binary.length; index += 1) {
const value = binary.charCodeAt(index);
const offset = index * 4;
imageData.data[offset] = value;
imageData.data[offset + 1] = value;
imageData.data[offset + 2] = value;
imageData.data[offset + 3] = 255;
const imageData = context.createImageData(fovCanvas.width, fovCanvas.height);
for (let y = 0; y < fovCanvas.height; y += 1) {
const sourceY = Math.min(preview.height - 1, Math.floor((y / fovCanvas.height) * preview.height));
for (let x = 0; x < fovCanvas.width; x += 1) {
const sourceX = Math.min(preview.width - 1, Math.floor((x / fovCanvas.width) * preview.width));
const value = binary.charCodeAt(sourceY * preview.width + sourceX);
const offset = (y * fovCanvas.width + x) * 4;
imageData.data[offset] = value;
imageData.data[offset + 1] = value;
imageData.data[offset + 2] = value;
imageData.data[offset + 3] = 255;
}
}
context.putImageData(imageData, 0, 0);
}
@@ -1217,25 +1336,27 @@ function drawVoxelOverlayLayer(
slice: number,
totalSlices: number,
): OverlayStats {
canvas.width = preview.width;
canvas.height = preview.height;
const fovCanvas = getFovCanvasSize(preview);
canvas.width = fovCanvas.width;
canvas.height = fovCanvas.height;
const context = canvas.getContext('2d');
if (!context) {
return { activeModules: 0, filledPixels: 0, segmentCount: 0 };
}
context.clearRect(0, 0, preview.width, preview.height);
const metrics = getModelSceneMetrics(files, previews);
context.clearRect(0, 0, fovCanvas.width, fovCanvas.height);
const metrics = getModelSceneMetrics(files, previews, preview, totalSlices);
if (!metrics) {
return { activeModules: 0, filledPixels: 0, segmentCount: 0 };
}
const normalizedSlice = totalSlices <= 1 ? 0.5 : clamp(slice, 0, totalSlices - 1) / (totalSlices - 1);
const targetZ = -1 + normalizedSlice * 2;
const canvasScale = Math.min(preview.width, preview.height) / (metrics.viewExtent * 2);
const safeSlice = clamp(slice, 0, Math.max(totalSlices - 1, 0));
const targetZ = totalSlices <= 1
? 0
: -metrics.dicomDepth / 2 + (metrics.dicomDepth * safeSlice) / (totalSlices - 1);
const mapPoint = (point: Point2D): Point2D => ({
x: preview.width / 2 + point.x * canvasScale,
y: preview.height / 2 - point.y * canvasScale,
x: ((point.x + metrics.dicomWidth / 2) / metrics.dicomWidth) * fovCanvas.width,
y: fovCanvas.height - ((point.y + metrics.dicomHeight / 2) / metrics.dicomHeight) * fovCanvas.height,
});
let activeModules = 0;
let filledPixels = 0;
@@ -1288,7 +1409,7 @@ function drawVoxelOverlayLayer(
}
}
const modulePixels = fillSegmentsAsSolidMask(context, preview.width, preview.height, segments, style.color, style.opacity);
const modulePixels = fillSegmentsAsSolidMask(context, fovCanvas.width, fovCanvas.height, segments, style.color, style.opacity);
if (segments.length > 0) {
activeModules += 1;
}
@@ -1364,7 +1485,7 @@ function VoxelizationMappingView({
let disposed = false;
setOverlayStatus('正在载入 STL 构件层级...');
Promise.allSettled(stlFiles.map((fileName) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 72000)}`)
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 200000)}`)
.then((response) => {
if (!response.ok) {
throw new Error('STL 构件预览加载失败');