2026-05-08-03-13-20 按DICOM范围切分模型

This commit is contained in:
2026-05-08 03:17:55 +08:00
parent 765e4cc41a
commit 3b133a1d43
5 changed files with 213 additions and 113 deletions

View File

@@ -106,59 +106,6 @@ function createDicomTexture(frame: string, width: number, height: number) {
return texture;
}
function createCutMaskTexture(frame: string, width: number, height: number) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return null;
}
const binary = atob(frame);
const imageData = context.createImageData(width, 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] = 245;
}
context.putImageData(imageData, 0, 0);
const cx = width * 0.52;
const cy = height * 0.52;
const rx = width * 0.19;
const ry = height * 0.15;
context.save();
context.beginPath();
context.ellipse(cx, cy, rx, ry, -0.12, 0, Math.PI * 2);
context.fillStyle = 'rgba(249, 115, 22, 0.36)';
context.fill();
context.lineWidth = Math.max(2, Math.round(Math.min(width, height) * 0.012));
context.strokeStyle = 'rgba(251, 191, 36, 0.95)';
context.stroke();
context.setLineDash([6, 4]);
context.lineWidth = Math.max(1, Math.round(Math.min(width, height) * 0.006));
context.strokeStyle = 'rgba(255, 255, 255, 0.72)';
context.stroke();
context.restore();
context.fillStyle = 'rgba(15, 23, 42, 0.72)';
context.fillRect(8, 8, 104, 22);
context.fillStyle = '#fed7aa';
context.font = 'bold 12px monospace';
context.fillText('CUT MASK', 16, 23);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
return texture;
}
function FusionThreeView({
project,
volume,
@@ -169,7 +116,8 @@ function FusionThreeView({
dicomOpacity,
showBounds,
cutEnabled,
cutSlice,
cutStart,
cutEnd,
}: {
project: Project;
volume: DicomFusionVolume | null;
@@ -180,7 +128,8 @@ function FusionThreeView({
dicomOpacity: { sliceOpacity: number; volumeOpacity: number; boxOpacity: number };
showBounds: boolean;
cutEnabled: boolean;
cutSlice: number;
cutStart: number;
cutEnd: number;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const modelPoseRef = useRef(modelPose);
@@ -253,23 +202,38 @@ function FusionThreeView({
edges.visible = showBounds;
dicomGroup.add(edges);
const cutZ = volume.total <= 1
? 0
: -dicomDepth / 2 + (dicomDepth * clamp(cutSlice, 0, volume.total - 1)) / (volume.total - 1);
const clippingPlane = new THREE.Plane(new THREE.Vector3(0, 0, -1), cutZ);
const cutPlane = new THREE.Mesh(
new THREE.PlaneGeometry(dicomWidth, dicomHeight),
new THREE.MeshBasicMaterial({
color: '#f97316',
transparent: true,
opacity: cutEnabled ? 0.24 : 0,
side: THREE.DoubleSide,
depthWrite: false,
}),
const sliceToZ = (sliceIndex: number) => (
volume.total <= 1
? 0
: -dicomDepth / 2 + (dicomDepth * clamp(sliceIndex, 0, volume.total - 1)) / (volume.total - 1)
);
cutPlane.position.set(0, 0, cutZ);
cutPlane.visible = cutEnabled;
dicomGroup.add(cutPlane);
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();
const createCutPlaneMaterial = () => new THREE.MeshBasicMaterial({
color: '#f97316',
transparent: true,
opacity: cutEnabled ? 0.16 : 0,
side: THREE.DoubleSide,
depthWrite: false,
});
const lowerCutPlane = new THREE.Mesh(new THREE.PlaneGeometry(dicomWidth, dicomHeight), createCutPlaneMaterial());
lowerCutPlane.position.set(0, 0, lowerCutZ);
lowerCutPlane.visible = cutEnabled;
dicomGroup.add(lowerCutPlane);
const upperCutPlane = new THREE.Mesh(new THREE.PlaneGeometry(dicomWidth, dicomHeight), createCutPlaneMaterial());
upperCutPlane.position.set(0, 0, upperCutZ);
upperCutPlane.visible = cutEnabled;
dicomGroup.add(upperCutPlane);
const textures: THREE.Texture[] = [];
volume.frames.forEach((frame, index) => {
@@ -293,27 +257,6 @@ function FusionThreeView({
dicomGroup.add(slicePlane);
});
if (cutEnabled && volume.frames.length) {
const nearestFrameIndex = volume.indices.reduce((bestIndex, currentIndex, candidateIndex) => (
Math.abs(currentIndex - cutSlice) < Math.abs((volume.indices[bestIndex] ?? 0) - cutSlice) ? candidateIndex : bestIndex
), 0);
const cutMaskTexture = createCutMaskTexture(volume.frames[nearestFrameIndex] ?? volume.frames[0], volume.width, volume.height);
if (cutMaskTexture) {
textures.push(cutMaskTexture);
const cutMaskPlane = new THREE.Mesh(
planeGeometry,
new THREE.MeshBasicMaterial({
map: cutMaskTexture,
transparent: true,
opacity: 0.96,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
cutMaskPlane.position.set(0, 0, cutZ + 0.018);
dicomGroup.add(cutMaskPlane);
}
}
setLoadProgress(42);
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
@@ -347,7 +290,8 @@ function FusionThreeView({
roughness: solidMode ? 0.56 : 0.48,
metalness: 0.03,
side: THREE.DoubleSide,
clippingPlanes: cutEnabled ? [clippingPlane] : [],
clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [],
clipIntersection: false,
clipShadows: true,
});
const mesh = new THREE.Mesh(geometry, material);
@@ -470,9 +414,13 @@ function FusionThreeView({
fusionRoot.scale.setScalar(rootPose.scale);
if (cutEnabled) {
fusionRoot.updateMatrixWorld(true);
const cutNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(fusionRoot.getWorldQuaternion(new THREE.Quaternion())).normalize();
const cutPoint = new THREE.Vector3(0, 0, cutZ).applyMatrix4(fusionRoot.matrixWorld);
clippingPlane.setFromNormalAndCoplanarPoint(cutNormal, cutPoint);
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;
@@ -529,7 +477,8 @@ function FusionThreeView({
dicomOpacity.boxOpacity,
showBounds,
cutEnabled,
cutSlice,
cutStart,
cutEnd,
]);
return (
@@ -569,7 +518,6 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low');
const [showBounds, setShowBounds] = useState(true);
const [cutEnabled, setCutEnabled] = useState(false);
const [cutSlice, setCutSlice] = useState(0);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [savedPoses, setSavedPoses] = useState<Array<{ id: string; name: string; pose: ModelPose }>>([
{ id: 'default', name: '默认', pose: defaultModelPose },
@@ -658,9 +606,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
api.getProject(projectId).then((item) => {
setProject(item);
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
setSliceStart(maxIndex);
setSliceStart(0);
setSliceEnd(maxIndex);
setCutSlice(maxIndex);
setModelPose(defaultModelPose);
const nextStyles: Record<string, ModuleStyle> = {};
(item.stlFiles ?? []).forEach((fileName, index) => {
@@ -904,7 +851,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
dicomOpacity={selectedDicomOpacity}
showBounds={showBounds}
cutEnabled={cutEnabled}
cutSlice={cutSlice}
cutStart={displayStart}
cutEnd={displayEnd}
/>
) : (
<div className="flex-1 rounded-3xl border border-slate-100 bg-white flex items-center justify-center text-sm text-slate-400">
@@ -1062,18 +1010,9 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
/>
</label>
</div>
<label className="grid grid-cols-[42px_1fr_34px] items-center gap-2 text-[10px] font-bold text-slate-500">
<input
type="range"
min="0"
max={maxSlice}
value={clamp(cutSlice, 0, maxSlice)}
onChange={(event) => setCutSlice(Number(event.target.value))}
className="accent-orange-500"
/>
<span className="text-right font-mono">{clamp(cutSlice, 0, maxSlice) + 1}</span>
</label>
<p className="rounded-lg bg-orange-50 px-2 py-2 text-[10px] font-bold leading-5 text-orange-700">
DICOM {displayStart + 1}-{displayEnd + 1}
</p>
</div>
<div>