2026-05-24-18-59-49 修正薄壳构件实体化映射
This commit is contained in:
@@ -142,7 +142,7 @@ const dicomVolumeCache = new Map<string, {
|
||||
}>();
|
||||
const modelPreviewCache = new Map<string, unknown>();
|
||||
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||
const maxPreviewTriangles = 500000;
|
||||
const maxPreviewTriangles = 800000;
|
||||
const defaultModelPose: ModelPoseValue = {
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
@@ -1288,7 +1288,76 @@ function closeExportMaskGaps(mask: Uint8Array, width: number, height: number, ma
|
||||
return toFill.size;
|
||||
}
|
||||
|
||||
function fillExportRows(data: Buffer, width: number, height: number, slice: number, rows: number[][], label: number) {
|
||||
function exportSolidStrokeRadius(width: number, height: number) {
|
||||
return Math.max(2.2, Math.min(5.5, Math.max(width, height) * 0.006));
|
||||
}
|
||||
|
||||
function paintExportMaskPixel(mask: Uint8Array, width: number, height: number, x: number, y: number) {
|
||||
if (x < 0 || x >= width || y < 0 || y >= height) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const index = y * width + x;
|
||||
if (mask[index]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
mask[index] = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function fillExportSegmentCapsules(
|
||||
mask: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
segments: PlaneSegmentRecord[],
|
||||
radius: number,
|
||||
) {
|
||||
let paintedPixels = 0;
|
||||
const radiusSquared = radius * radius;
|
||||
|
||||
segments.forEach(({ a, b }) => {
|
||||
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const lengthSquared = dx * dx + dy * dy;
|
||||
const minX = clampNumber(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1);
|
||||
const maxX = clampNumber(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1);
|
||||
const minY = clampNumber(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1);
|
||||
const maxY = clampNumber(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1);
|
||||
|
||||
for (let y = minY; y <= maxY; y += 1) {
|
||||
for (let x = minX; x <= maxX; x += 1) {
|
||||
const px = x + 0.5;
|
||||
const py = y + 0.5;
|
||||
const t = lengthSquared <= 1e-6
|
||||
? 0
|
||||
: clampNumber(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1);
|
||||
const closestX = a.x + dx * t;
|
||||
const closestY = a.y + dy * t;
|
||||
const distanceSquared = (px - closestX) ** 2 + (py - closestY) ** 2;
|
||||
if (distanceSquared <= radiusSquared) {
|
||||
paintedPixels += paintExportMaskPixel(mask, width, height, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return paintedPixels;
|
||||
}
|
||||
|
||||
function fillExportRows(
|
||||
data: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
slice: number,
|
||||
rows: number[][],
|
||||
label: number,
|
||||
solidSegments: PlaneSegmentRecord[] = [],
|
||||
) {
|
||||
const mask = new Uint8Array(width * height);
|
||||
let filledPixels = 0;
|
||||
|
||||
@@ -1325,11 +1394,15 @@ function fillExportRows(data: Buffer, width: number, height: number, slice: numb
|
||||
}
|
||||
});
|
||||
|
||||
if (solidSegments.length) {
|
||||
filledPixels += fillExportSegmentCapsules(mask, width, height, solidSegments, exportSolidStrokeRadius(width, height));
|
||||
}
|
||||
|
||||
if (filledPixels === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
filledPixels += closeExportMaskGaps(mask, width, height);
|
||||
filledPixels += closeExportMaskGaps(mask, width, height, 3);
|
||||
filledPixels += fillExportInternalHoles(mask, width, height);
|
||||
const sliceOffset = slice * width * height;
|
||||
for (let index = 0; index < mask.length; index += 1) {
|
||||
@@ -1527,11 +1600,11 @@ function createSegmentationData(
|
||||
});
|
||||
|
||||
slicesByIndex.forEach(({ segments }, slice) => {
|
||||
groupExportSegmentsByConnectivity(segments).forEach((group) => {
|
||||
groupExportSegmentsByConnectivity(segments, exportSolidStrokeRadius(volume.width, volume.height) * 1.15).forEach((group) => {
|
||||
const rows = Array.from({ length: volume.height }, () => [] as number[]);
|
||||
group.forEach((segment) => addExportSegmentToRows(rows, volume.width, volume.height, segment));
|
||||
const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label);
|
||||
if (filledPixels < Math.max(12, Math.round(group.length * 0.45)) && group.length >= 3) {
|
||||
const filledPixels = fillExportRows(data, volume.width, volume.height, slice, rows, label, group);
|
||||
if (filledPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) {
|
||||
fillExportFallbackClosedRegion(data, volume.width, volume.height, slice, group, label);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }
|
||||
{ id: 'standard', label: '标准', limit: 16000 },
|
||||
{ id: 'fine', label: '精细', limit: 36000 },
|
||||
{ id: 'ultra', label: '超精细', limit: 72000 },
|
||||
{ id: 'solid', label: '实体', limit: 200000 },
|
||||
{ id: 'solid', label: '实体', limit: 800000 },
|
||||
];
|
||||
const defaultModelPose: ModelPose = {
|
||||
rotateX: 0,
|
||||
@@ -703,22 +703,24 @@ function NativeStlViewer({
|
||||
let loaded = 0;
|
||||
let failed = 0;
|
||||
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||||
const modelPreviewLimit = solidMode ? Math.max(detailLimit, 800000) : detailLimit;
|
||||
|
||||
visibleFiles.forEach((fileName) => {
|
||||
getCachedModelPreview(projectId, fileName, detailLimit)
|
||||
getCachedModelPreview(projectId, fileName, modelPreviewLimit)
|
||||
.then((payload) => {
|
||||
if (disposed) return;
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
|
||||
geometry.computeVertexNormals();
|
||||
const style = styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 };
|
||||
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity;
|
||||
const materialOpacity = solidMode ? 1 : style.opacity;
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: style.color,
|
||||
opacity: materialOpacity,
|
||||
transparent: materialOpacity < 1,
|
||||
depthWrite: materialOpacity >= 1,
|
||||
roughness: solidMode ? 0.56 : 0.42,
|
||||
metalness: 0.04,
|
||||
side: THREE.DoubleSide,
|
||||
|
||||
@@ -70,7 +70,7 @@ export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: num
|
||||
{ id: 'standard', label: '标准', limit: 16000 },
|
||||
{ id: 'fine', label: '精细', limit: 36000 },
|
||||
{ id: 'ultra', label: '超精细', limit: 72000 },
|
||||
{ id: 'solid', label: '实体', limit: 200000 },
|
||||
{ id: 'solid', label: '实体', limit: 800000 },
|
||||
];
|
||||
export const dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOpacity: number; volumeOpacity: number; boxOpacity: number }> = [
|
||||
{ id: 'low', label: '低', sliceOpacity: 0.82, volumeOpacity: 0.12, boxOpacity: 0.32 },
|
||||
@@ -707,13 +707,14 @@ export function FusionThreeView({
|
||||
|
||||
const stlFiles = project.stlFiles ?? [];
|
||||
const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
||||
const modelPreviewLimit = solidMode ? Math.max(detailLimit, 800000) : detailLimit;
|
||||
let modelBaseScale = 1;
|
||||
let loadedModels = 0;
|
||||
let failedModels = 0;
|
||||
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||||
|
||||
Promise.allSettled(stlFiles.map((fileName, index) => (
|
||||
getCachedModelPreview(project.id, fileName, detailLimit)
|
||||
getCachedModelPreview(project.id, fileName, modelPreviewLimit)
|
||||
.then((payload) => {
|
||||
if (disposed) return;
|
||||
const style = moduleStyles[fileName] ?? {
|
||||
@@ -732,11 +733,12 @@ export function FusionThreeView({
|
||||
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 materialOpacity = solidMode ? 1 : style.opacity;
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: style.color,
|
||||
transparent: true,
|
||||
transparent: materialOpacity < 1,
|
||||
opacity: materialOpacity,
|
||||
depthWrite: materialOpacity >= 1,
|
||||
roughness: solidMode ? 0.56 : 0.48,
|
||||
metalness: 0.03,
|
||||
side: THREE.DoubleSide,
|
||||
@@ -1816,6 +1818,80 @@ function closeSmallMaskGaps(
|
||||
return toFill.size;
|
||||
}
|
||||
|
||||
function solidStrokeRadius(width: number, height: number) {
|
||||
return Math.max(2.2, Math.min(5.5, Math.max(width, height) * 0.006));
|
||||
}
|
||||
|
||||
function paintMaskPixel(
|
||||
maskData: ImageData,
|
||||
width: number,
|
||||
height: number,
|
||||
x: number,
|
||||
y: number,
|
||||
rgb: { r: number; g: number; b: number },
|
||||
alpha: number,
|
||||
) {
|
||||
if (x < 0 || x >= width || y < 0 || y >= height) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const offset = (y * width + x) * 4;
|
||||
if (maskData.data[offset + 3] > 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
maskData.data[offset] = rgb.r;
|
||||
maskData.data[offset + 1] = rgb.g;
|
||||
maskData.data[offset + 2] = rgb.b;
|
||||
maskData.data[offset + 3] = alpha;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function fillSegmentCapsulesIntoMask(
|
||||
maskData: ImageData,
|
||||
width: number,
|
||||
height: number,
|
||||
segments: PlaneSegment[],
|
||||
rgb: { r: number; g: number; b: number },
|
||||
alpha: number,
|
||||
radius: number,
|
||||
) {
|
||||
let paintedPixels = 0;
|
||||
const radiusSquared = radius * radius;
|
||||
|
||||
segments.forEach(({ a, b }) => {
|
||||
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const lengthSquared = dx * dx + dy * dy;
|
||||
const minX = clamp(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1);
|
||||
const maxX = clamp(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1);
|
||||
const minY = clamp(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1);
|
||||
const maxY = clamp(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1);
|
||||
|
||||
for (let y = minY; y <= maxY; y += 1) {
|
||||
for (let x = minX; x <= maxX; x += 1) {
|
||||
const px = x + 0.5;
|
||||
const py = y + 0.5;
|
||||
const t = lengthSquared <= 1e-6
|
||||
? 0
|
||||
: clamp(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1);
|
||||
const closestX = a.x + dx * t;
|
||||
const closestY = a.y + dy * t;
|
||||
const distanceSquared = (px - closestX) ** 2 + (py - closestY) ** 2;
|
||||
if (distanceSquared <= radiusSquared) {
|
||||
paintedPixels += paintMaskPixel(maskData, width, height, x, y, rgb, alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return paintedPixels;
|
||||
}
|
||||
|
||||
function drawFallbackClosedRegion(
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
@@ -1912,7 +1988,8 @@ function fillSegmentsAsSolidMask(
|
||||
}
|
||||
const maskData = maskContext.createImageData(width, height);
|
||||
let filledPixels = 0;
|
||||
const groups = groupPlaneSegmentsByConnectivity(segments);
|
||||
const radius = solidStrokeRadius(width, height);
|
||||
const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15);
|
||||
const fallbackGroups: PlaneSegment[][] = [];
|
||||
|
||||
groups.forEach((group) => {
|
||||
@@ -1959,14 +2036,15 @@ function fillSegmentsAsSolidMask(
|
||||
}
|
||||
}
|
||||
});
|
||||
groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius);
|
||||
|
||||
filledPixels += groupPixels;
|
||||
if (groupPixels < Math.max(12, Math.round(group.length * 0.45)) && group.length >= 3) {
|
||||
if (groupPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) {
|
||||
fallbackGroups.push(group);
|
||||
}
|
||||
});
|
||||
|
||||
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha);
|
||||
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3);
|
||||
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
|
||||
maskContext.putImageData(maskData, 0, 0);
|
||||
context.drawImage(maskCanvas, 0, 0);
|
||||
@@ -2221,7 +2299,7 @@ export function VoxelizationMappingView({
|
||||
let disposed = false;
|
||||
let loaded = 0;
|
||||
const total = visibleStlFiles.length;
|
||||
const previewLimit = Math.max(detailLimit, 500000);
|
||||
const previewLimit = Math.max(detailLimit, 800000);
|
||||
const updateLoadProgress = (phase: string) => {
|
||||
if (!disposed) {
|
||||
setOverlayLoadState({ loading: true, loaded, total, phase });
|
||||
@@ -3396,7 +3474,7 @@ export default function ReverseWorkspace({
|
||||
const fusionStart = Math.min(displayStart, displayEnd);
|
||||
const fusionEnd = Math.max(displayStart, displayEnd);
|
||||
const previewLimit = selectedDisplay.limit;
|
||||
const mappingPreviewLimit = Math.max(previewLimit, 200000);
|
||||
const mappingPreviewLimit = Math.max(previewLimit, 800000);
|
||||
const total = 2 + stlFilesForLoad.length * 2;
|
||||
const startedAt = Date.now();
|
||||
let loaded = 0;
|
||||
|
||||
Reference in New Issue
Block a user