2026-05-20-00-38-39 对齐FOV并强化网格截面填充
This commit is contained in:
@@ -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 构件预览加载失败');
|
||||
|
||||
Reference in New Issue
Block a user