2026-05-08-03-35-22 展示STL切割实体切面
This commit is contained in:
@@ -1,9 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import {
|
import {
|
||||||
Dices,
|
Dices,
|
||||||
Settings2,
|
Settings2,
|
||||||
Maximize2,
|
|
||||||
Download,
|
Download,
|
||||||
Rotate3d,
|
Rotate3d,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -12,7 +10,7 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { DicomFusionVolume, MaskMapping, ModuleStyle, Project } from '../types';
|
import { DicomFusionVolume, ModuleStyle, Project } from '../types';
|
||||||
import { api, downloadMask } from '../lib/api';
|
import { api, downloadMask } from '../lib/api';
|
||||||
|
|
||||||
interface ModelPose {
|
interface ModelPose {
|
||||||
@@ -495,6 +493,305 @@ function FusionThreeView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CutSectionPreview({
|
||||||
|
project,
|
||||||
|
volume,
|
||||||
|
modelPose,
|
||||||
|
moduleStyles,
|
||||||
|
detailLimit,
|
||||||
|
cutEnabled,
|
||||||
|
cutStart,
|
||||||
|
cutEnd,
|
||||||
|
}: {
|
||||||
|
project: Project | null;
|
||||||
|
volume: DicomFusionVolume | null;
|
||||||
|
modelPose: ModelPose;
|
||||||
|
moduleStyles: Record<string, ModuleStyle>;
|
||||||
|
detailLimit: number;
|
||||||
|
cutEnabled: boolean;
|
||||||
|
cutStart: number;
|
||||||
|
cutEnd: number;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const modelPoseRef = useRef(modelPose);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
modelPoseRef.current = modelPose;
|
||||||
|
}, [modelPose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container || !project || !volume) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
let disposed = false;
|
||||||
|
let animationId = 0;
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color('#020617');
|
||||||
|
|
||||||
|
const width = Math.max(container.clientWidth, 1);
|
||||||
|
const height = Math.max(container.clientHeight, 1);
|
||||||
|
const camera = new THREE.PerspectiveCamera(42, width / height, 0.05, 1000);
|
||||||
|
camera.position.set(0, -5.6, 3.4);
|
||||||
|
camera.up.set(0, 0, 1);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
renderer.localClippingEnabled = true;
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.78));
|
||||||
|
const keyLight = new THREE.DirectionalLight(0xffffff, 1.25);
|
||||||
|
keyLight.position.set(3, -4, 5);
|
||||||
|
scene.add(keyLight);
|
||||||
|
const rimLight = new THREE.DirectionalLight(0x93c5fd, 0.72);
|
||||||
|
rimLight.position.set(-4, 3, 2);
|
||||||
|
scene.add(rimLight);
|
||||||
|
|
||||||
|
const fusionRoot = new THREE.Group();
|
||||||
|
const modelPoseGroup = new THREE.Group();
|
||||||
|
const modelPivot = new THREE.Group();
|
||||||
|
modelPoseGroup.add(modelPivot);
|
||||||
|
fusionRoot.add(modelPoseGroup);
|
||||||
|
scene.add(fusionRoot);
|
||||||
|
|
||||||
|
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
|
||||||
|
const baseExtent = 4.4;
|
||||||
|
const dicomWidth = (volume.physicalSize.width / maxPhysical) * baseExtent;
|
||||||
|
const dicomHeight = (volume.physicalSize.height / maxPhysical) * baseExtent;
|
||||||
|
const dicomDepth = Math.max((volume.physicalSize.depth / maxPhysical) * baseExtent, 0.18);
|
||||||
|
const sliceToZ = (sliceIndex: number) => (
|
||||||
|
volume.total <= 1
|
||||||
|
? 0
|
||||||
|
: -dicomDepth / 2 + (dicomDepth * clamp(sliceIndex, 0, volume.total - 1)) / (volume.total - 1)
|
||||||
|
);
|
||||||
|
const cutRangeStart = Math.min(
|
||||||
|
clamp(cutStart, 0, volume.total - 1),
|
||||||
|
clamp(cutEnd, 0, volume.total - 1),
|
||||||
|
);
|
||||||
|
const cutRangeEnd = Math.max(
|
||||||
|
clamp(cutStart, 0, volume.total - 1),
|
||||||
|
clamp(cutEnd, 0, volume.total - 1),
|
||||||
|
);
|
||||||
|
const lowerCutZ = sliceToZ(cutRangeStart);
|
||||||
|
const upperCutZ = sliceToZ(cutRangeEnd);
|
||||||
|
const lowerClippingPlane = new THREE.Plane();
|
||||||
|
const upperClippingPlane = new THREE.Plane();
|
||||||
|
|
||||||
|
let modelBaseScale = 1;
|
||||||
|
let loadedModels = 0;
|
||||||
|
let failedModels = 0;
|
||||||
|
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||||||
|
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
||||||
|
|
||||||
|
Promise.allSettled(stlFiles.map((fileName, index) => (
|
||||||
|
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 200000)}`)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error('模型切面加载失败');
|
||||||
|
return response.json() as Promise<ModelPreviewPayload>;
|
||||||
|
})
|
||||||
|
.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: 1,
|
||||||
|
partId: index + 1,
|
||||||
|
};
|
||||||
|
const material = new THREE.MeshStandardMaterial({
|
||||||
|
color: style.color,
|
||||||
|
roughness: 0.5,
|
||||||
|
metalness: 0.04,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [],
|
||||||
|
clipIntersection: false,
|
||||||
|
clipShadows: true,
|
||||||
|
});
|
||||||
|
modelPivot.add(new THREE.Mesh(geometry, material));
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadedModels += 1;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
failedModels += 1;
|
||||||
|
})
|
||||||
|
))).then(() => {
|
||||||
|
if (disposed || (loadedModels + failedModels === 0)) return;
|
||||||
|
const modelBox = new THREE.Box3();
|
||||||
|
if (loadedBounds.length) {
|
||||||
|
loadedBounds.forEach((bounds) => {
|
||||||
|
modelBox.expandByPoint(bounds.min);
|
||||||
|
modelBox.expandByPoint(bounds.max);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
modelBox.setFromObject(modelPivot);
|
||||||
|
}
|
||||||
|
const center = modelBox.getCenter(new THREE.Vector3());
|
||||||
|
const size = modelBox.getSize(new THREE.Vector3());
|
||||||
|
const maxModelSize = Math.max(size.x, size.y, size.z, 1);
|
||||||
|
modelPivot.traverse((object) => {
|
||||||
|
if (object instanceof THREE.Mesh) {
|
||||||
|
object.geometry.translate(-center.x, -center.y, -center.z);
|
||||||
|
object.geometry.computeBoundingBox();
|
||||||
|
object.geometry.computeBoundingSphere();
|
||||||
|
object.geometry.computeVertexNormals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.98;
|
||||||
|
modelPoseGroup.position.set(0, 0, 0);
|
||||||
|
modelPivot.position.set(0, 0, dicomDepth * 0.08);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootPose = {
|
||||||
|
rotateX: THREE.MathUtils.degToRad(58),
|
||||||
|
rotateY: 0,
|
||||||
|
rotateZ: THREE.MathUtils.degToRad(-18),
|
||||||
|
translateX: 0,
|
||||||
|
translateY: 0,
|
||||||
|
scale: 1,
|
||||||
|
};
|
||||||
|
const dragState = {
|
||||||
|
active: false,
|
||||||
|
mode: 'rotate' as 'rotate' | 'pan',
|
||||||
|
pointerId: 0,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
root: { ...rootPose },
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
dragState.active = true;
|
||||||
|
dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate';
|
||||||
|
dragState.pointerId = event.pointerId;
|
||||||
|
dragState.startX = event.clientX;
|
||||||
|
dragState.startY = event.clientY;
|
||||||
|
dragState.root = { ...rootPose };
|
||||||
|
container.setPointerCapture(event.pointerId);
|
||||||
|
};
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
if (!dragState.active || event.pointerId !== dragState.pointerId) return;
|
||||||
|
const deltaX = event.clientX - dragState.startX;
|
||||||
|
const deltaY = event.clientY - dragState.startY;
|
||||||
|
if (dragState.mode === 'pan') {
|
||||||
|
rootPose.translateX = dragState.root.translateX + deltaX * 0.006;
|
||||||
|
rootPose.translateY = dragState.root.translateY - deltaY * 0.006;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rootPose.rotateZ = dragState.root.rotateZ + deltaX * 0.008;
|
||||||
|
rootPose.rotateX = dragState.root.rotateX + deltaY * 0.008;
|
||||||
|
};
|
||||||
|
const stopPointerDrag = (event: PointerEvent) => {
|
||||||
|
if (event.pointerId !== dragState.pointerId) return;
|
||||||
|
dragState.active = false;
|
||||||
|
if (container.hasPointerCapture(event.pointerId)) {
|
||||||
|
container.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
rootPose.scale = clamp(rootPose.scale - event.deltaY * 0.001, 0.55, 2.4);
|
||||||
|
};
|
||||||
|
const preventContextMenu = (event: MouseEvent) => event.preventDefault();
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!container.clientWidth || !container.clientHeight) return;
|
||||||
|
camera.aspect = container.clientWidth / container.clientHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener('pointerdown', handlePointerDown);
|
||||||
|
container.addEventListener('pointermove', handlePointerMove);
|
||||||
|
container.addEventListener('pointerup', stopPointerDrag);
|
||||||
|
container.addEventListener('pointercancel', stopPointerDrag);
|
||||||
|
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
container.addEventListener('contextmenu', preventContextMenu);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ);
|
||||||
|
fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0);
|
||||||
|
fusionRoot.scale.setScalar(rootPose.scale);
|
||||||
|
if (cutEnabled) {
|
||||||
|
fusionRoot.updateMatrixWorld(true);
|
||||||
|
const rootQuaternion = fusionRoot.getWorldQuaternion(new THREE.Quaternion());
|
||||||
|
const lowerNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(rootQuaternion).normalize();
|
||||||
|
const upperNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(rootQuaternion).normalize();
|
||||||
|
const lowerCutPoint = new THREE.Vector3(0, 0, lowerCutZ).applyMatrix4(fusionRoot.matrixWorld);
|
||||||
|
const upperCutPoint = new THREE.Vector3(0, 0, upperCutZ).applyMatrix4(fusionRoot.matrixWorld);
|
||||||
|
lowerClippingPlane.setFromNormalAndCoplanarPoint(lowerNormal, lowerCutPoint);
|
||||||
|
upperClippingPlane.setFromNormalAndCoplanarPoint(upperNormal, upperCutPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pose = modelPoseRef.current;
|
||||||
|
modelPoseGroup.rotation.set(
|
||||||
|
THREE.MathUtils.degToRad(pose.rotateX),
|
||||||
|
THREE.MathUtils.degToRad(pose.rotateY),
|
||||||
|
THREE.MathUtils.degToRad(pose.rotateZ),
|
||||||
|
);
|
||||||
|
modelPoseGroup.position.set(pose.translateX, pose.translateY, pose.translateZ);
|
||||||
|
modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
animationId = window.requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.cancelAnimationFrame(animationId);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
container.removeEventListener('pointerdown', handlePointerDown);
|
||||||
|
container.removeEventListener('pointermove', handlePointerMove);
|
||||||
|
container.removeEventListener('pointerup', stopPointerDrag);
|
||||||
|
container.removeEventListener('pointercancel', stopPointerDrag);
|
||||||
|
container.removeEventListener('wheel', handleWheel);
|
||||||
|
container.removeEventListener('contextmenu', preventContextMenu);
|
||||||
|
scene.traverse((object) => {
|
||||||
|
if (object instanceof THREE.Mesh) {
|
||||||
|
object.geometry.dispose();
|
||||||
|
const material = object.material;
|
||||||
|
if (Array.isArray(material)) {
|
||||||
|
material.forEach((item) => item.dispose());
|
||||||
|
} else {
|
||||||
|
material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderer.dispose();
|
||||||
|
container.innerHTML = '';
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
project?.id,
|
||||||
|
project?.stlFiles?.join('|'),
|
||||||
|
volume,
|
||||||
|
JSON.stringify(moduleStyles),
|
||||||
|
detailLimit,
|
||||||
|
cutEnabled,
|
||||||
|
cutStart,
|
||||||
|
cutEnd,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full min-h-[420px] overflow-hidden rounded-3xl border border-slate-800 bg-slate-950 shadow-2xl">
|
||||||
|
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
|
||||||
|
{(!project || !volume) && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-white/40">
|
||||||
|
正在载入 STL 切面...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||||
const [sliceStart, setSliceStart] = useState(0);
|
const [sliceStart, setSliceStart] = useState(0);
|
||||||
const [sliceEnd, setSliceEnd] = useState(49);
|
const [sliceEnd, setSliceEnd] = useState(49);
|
||||||
@@ -516,16 +813,9 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
|
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
|
||||||
const [fusionError, setFusionError] = useState('');
|
const [fusionError, setFusionError] = useState('');
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [exportMessage, setExportMessage] = useState('准备就绪');
|
|
||||||
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
|
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
|
||||||
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
|
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
|
||||||
|
|
||||||
const [mappings] = useState<MaskMapping[]>([
|
|
||||||
{ className: '骨样组织', color: '#ff4d4f', maskId: 1 },
|
|
||||||
{ className: '神经根', color: '#52c41a', maskId: 2 },
|
|
||||||
{ className: '血管', color: '#1890ff', maskId: 3 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleStartRegistration = () => {
|
const handleStartRegistration = () => {
|
||||||
setIsRegistering(true);
|
setIsRegistering(true);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
@@ -533,12 +823,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
const handleExport = async (format: 'nii' | 'nii.gz') => {
|
const handleExport = async (format: 'nii' | 'nii.gz') => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setExportMessage(`正在生成 ${format.toUpperCase()} 分割 Mask...`);
|
|
||||||
try {
|
try {
|
||||||
await downloadMask(projectId, format);
|
await downloadMask(projectId, format);
|
||||||
setExportMessage(`${format.toUpperCase()} 分割 Mask 已生成并开始下载`);
|
} catch (error) {
|
||||||
} catch (err) {
|
setFusionError(error instanceof Error ? error.message : '导出失败');
|
||||||
setExportMessage(err instanceof Error ? err.message : '导出失败');
|
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -1151,52 +1439,16 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 bg-slate-900 rounded-3xl border border-slate-800 shadow-2xl relative overflow-hidden flex items-center justify-center">
|
<CutSectionPreview
|
||||||
<div className="relative w-64 h-64">
|
project={project}
|
||||||
<div className="absolute inset-0 opacity-10 blur-xl bg-white rounded-full translate-x-4 translate-y-4" />
|
volume={fusionVolume}
|
||||||
{mappings.map((mapping, index) => (
|
modelPose={modelPose}
|
||||||
<motion.div
|
moduleStyles={moduleStyles}
|
||||||
key={mapping.maskId}
|
detailLimit={selectedDisplay.limit}
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
cutEnabled={cutEnabled}
|
||||||
animate={{ scale: 1, opacity: 0.8 }}
|
cutStart={displayStart}
|
||||||
transition={{ delay: index * 0.2 }}
|
cutEnd={displayEnd}
|
||||||
className="absolute inset-0 border-2"
|
|
||||||
style={{
|
|
||||||
borderColor: mapping.color,
|
|
||||||
borderRadius: index === 0 ? '30% 70% 70% 30% / 30% 30% 70% 70%' : '60% 40% 30% 70% / 60% 30% 70% 40%',
|
|
||||||
background: `${mapping.color}20`,
|
|
||||||
boxShadow: `inset 0 0 20px ${mapping.color}40`,
|
|
||||||
transform: `rotate(${index * 45 + displayStart}deg) scale(${1 - index * 0.1})`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
||||||
<div className="w-full h-0.5 bg-blue-500/20 absolute" />
|
|
||||||
<div className="h-full w-0.5 bg-blue-500/20 absolute" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute top-4 left-4 z-20 flex gap-2">
|
|
||||||
<span className="px-2 py-1 bg-blue-600/20 border border-blue-500/30 text-blue-400 text-[9px] font-bold rounded uppercase">Inferred Mask</span>
|
|
||||||
<span className="px-2 py-1 bg-emerald-600/20 border border-emerald-500/30 text-emerald-400 text-[9px] font-bold rounded uppercase">Verified</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute bottom-4 right-4">
|
|
||||||
<button className="p-2 bg-white/5 hover:bg-white/10 text-white/50 rounded-lg backdrop-blur-sm transition-all">
|
|
||||||
<Maximize2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-16 shrink-0 bg-white rounded-2xl border border-slate-100 shadow-sm flex items-center justify-between px-6">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">导出进度</span>
|
|
||||||
<span className="text-xs font-bold text-slate-700">{exportMessage},包含 {mappings.length} 个标注层级</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-24 bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
|
||||||
<div className="bg-blue-600 h-full w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
50
工程分析/实现方案-2026-05-08-03-35-22.md
Normal file
50
工程分析/实现方案-2026-05-08-03-35-22.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 实现方案:右侧实体切面预览
|
||||||
|
|
||||||
|
时间戳:2026-05-08-03-35-22
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
在 `Mask 展示` 区域新增真实 STL 切割实体预览组件,替代旧的二维示意 Mask,并删除底部导出进度栏。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
1. 新增 `CutSectionPreview` 组件。
|
||||||
|
2. 组件使用 Three.js 渲染 STL preview 顶点数据。
|
||||||
|
3. 使用 DICOM `physicalSize.depth` 和 `total` 将 `displayStart/displayEnd` 映射到 Z 方向两张 clipping plane。
|
||||||
|
4. 当“模型切分”启用时,对 STL 材质应用两张 clipping plane,保留中间实体区域;未启用时展示完整实体模型。
|
||||||
|
5. 复用构件颜色、显示隐藏、模型位姿和高质量实体请求上限。
|
||||||
|
6. 删除旧 `mappings` 假 Mask 形状、`MaskMapping` 类型引用、`exportMessage` 和底部导出进度栏。
|
||||||
|
|
||||||
|
## 数据流或交互流程
|
||||||
|
|
||||||
|
- DICOM 范围条更新 `displayStart/displayEnd`。
|
||||||
|
- `FusionThreeView` 和 `CutSectionPreview` 都接收相同范围。
|
||||||
|
- `CutSectionPreview` 请求 STL preview,居中、缩放并应用当前模型位姿。
|
||||||
|
- 启用模型切分后,右侧实体预览显示裁切后的 STL 中间段。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- 若实体预览性能不满足要求,可降低 `detailLimit` 或回滚到旧 Mask 展示区域。
|
||||||
|
- 本次不改 API 和数据文件,回滚只涉及前端组件。
|
||||||
|
|
||||||
|
## 风险控制
|
||||||
|
|
||||||
|
- 使用 `npm run lint` 验证类型。
|
||||||
|
- 使用 `npm run build` 验证构建。
|
||||||
|
- 使用 `rg` 确认旧 `导出进度`、`mappings`、假 Mask 结构已移除。
|
||||||
|
- 重启服务并验证 4000 端口。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- 修改 `ReverseWorkspace.tsx`。
|
||||||
|
- 新增本次需求、实现、测试文档。
|
||||||
|
- 追加经验记录。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已在项目工作流历史中确认后续直接执行,本次不等待二次人工审核。
|
||||||
42
工程分析/测试方案-2026-05-08-03-35-22.md
Normal file
42
工程分析/测试方案-2026-05-08-03-35-22.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 测试方案:右侧 STL 实体切面预览
|
||||||
|
|
||||||
|
时间戳:2026-05-08-03-35-22
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
- 执行 `npm run lint`。
|
||||||
|
- 执行 `npm run build`。
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
- 逆向工作区右侧 `Mask 展示` 不再出现旧的二维彩色假 Mask。
|
||||||
|
- 右侧区域显示 STL 实体模型。
|
||||||
|
- 启用模型切分并调整 DICOM 范围后,右侧实体预览按同一范围裁切。
|
||||||
|
- 构件隐藏、颜色、模型位姿调整后,右侧实体预览同步更新。
|
||||||
|
- 底部“导出进度”栏不再显示。
|
||||||
|
|
||||||
|
## 回归风险
|
||||||
|
|
||||||
|
- 右侧新增 Three.js 渲染可能增加 GPU/CPU 占用。
|
||||||
|
- 当前无法自动截图确认 WebGL 视觉结果,需要人工刷新页面观察。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 源码不再包含 `导出进度`、旧 `mappings.map` 假 Mask 结构。
|
||||||
|
- `npm run lint` 和 `npm run build` 均通过。
|
||||||
|
- 重新部署后 `http://192.168.3.11:4000/` 返回 200。
|
||||||
|
|
||||||
|
## 无法测试的风险
|
||||||
|
|
||||||
|
- 无法在当前命令行直接确认 STL 切面视觉是否符合用户预期,需要用户浏览器中观察。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已在项目工作流历史中确认后续直接执行,本次不等待二次人工审核。
|
||||||
|
|
||||||
|
## 执行结果
|
||||||
|
|
||||||
|
- `npm run lint`:通过。
|
||||||
|
- `npm run build`:通过;仅保留 Vite chunk 大小提示。
|
||||||
|
- `rg` 验证:`ReverseWorkspace.tsx` 不再包含 `mappings`、`exportMessage`、`导出进度`、`Maximize2`、`Inferred Mask`、`Verified` 等旧示意 Mask 和导出进度栏结构。
|
||||||
|
- `rg` 验证:`ReverseWorkspace.tsx` 已新增 `CutSectionPreview` 并挂载到右侧 `Mask 展示` 区域。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -775,3 +775,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
新增导航入口前先判断是否是独立业务对象和独立工作流,避免用多个入口指向同一组件状态;辅助视觉元素应在用户确认有价值后保留,医学三维视图中默认少放装饰或调试平面,避免遮挡真实数据。
|
新增导航入口前先判断是否是独立业务对象和独立工作流,避免用多个入口指向同一组件状态;辅助视觉元素应在用户确认有价值后保留,医学三维视图中默认少放装饰或调试平面,避免遮挡真实数据。
|
||||||
|
|
||||||
|
## 2026-05-08-03-35-22 Mask 展示改为真实 STL 切面
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
逆向工作区右侧 `Mask 展示` 仍然是二维彩色示意图,并且底部存在“导出进度”栏;用户希望看到切割后的 STL 实体切面。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
旧 `Mask 展示` 是早期演示 UI,没有接入真实 STL 模型,也没有复用 DICOM 范围切割逻辑;导出进度栏展示的是导出状态文本,不是用户当前关注的切面实体结果。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
新增 `CutSectionPreview` Three.js 组件,复用当前项目 STL preview、构件颜色/隐藏状态、模型位姿和 DICOM 切片范围;启用模型切分时使用两张 clipping plane 保留中间区域,并以实体材质展示裁切后的 STL。删除旧二维 Mask 示意结构和底部“导出进度”栏。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
右侧结果展示区域应优先呈现真实数据或真实处理结果,避免使用占位式示意图长期冒充结果;如果尚未生成真实语义分割 Mask,应明确展示当前可验证的 STL 切面实体,而不是伪造 Mask 形状。
|
||||||
|
|||||||
41
工程分析/需求分析-2026-05-08-03-35-22.md
Normal file
41
工程分析/需求分析-2026-05-08-03-35-22.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 需求分析:Mask 展示改为切割 STL 实体预览
|
||||||
|
|
||||||
|
时间戳:2026-05-08-03-35-22
|
||||||
|
|
||||||
|
## 原始需求
|
||||||
|
|
||||||
|
1. 在逆向工作区右侧“Mask 展示”中展示切割后的 STL 切面,要求为实体展示。
|
||||||
|
2. 删除下方“导出进度”栏。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
- 将右侧旧的示意 Mask 图替换为真实 STL 模型切割结果预览。
|
||||||
|
- 右侧预览使用当前 DICOM 切片范围与模型切分状态,展示裁切后的 STL 实体。
|
||||||
|
- 删除底部导出进度信息栏,减少无意义 UI。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
- 输入:当前项目 STL 文件、构件显示状态、构件颜色、模型位姿、DICOM 切片范围、模型切分开关。
|
||||||
|
- 输出:右侧 `Mask 展示` 区域中的 Three.js 实体模型切面预览。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 不生成伪造 Mask 图片。
|
||||||
|
- 不改变后端 API 和导出接口。
|
||||||
|
- 保留顶部导出按钮;只删除下方“导出进度”栏。
|
||||||
|
- STL 实体切面需要沿用当前 DICOM 范围裁切逻辑。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 新增一个 Three.js 视图会增加前端渲染负载。
|
||||||
|
- 切面预览与左侧融合视图需要共享同一套 DICOM range 到 clipping plane 的映射,避免视觉不一致。
|
||||||
|
- 模型位姿、构件隐藏和颜色需要在两个视图中保持同步。
|
||||||
|
|
||||||
|
## 待确认事项
|
||||||
|
|
||||||
|
- 用户已确认后续直接执行,本次不等待二次人工审核。
|
||||||
Reference in New Issue
Block a user