1224 lines
52 KiB
TypeScript
1224 lines
52 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
||
import { motion } from 'motion/react';
|
||
import {
|
||
Dices,
|
||
Settings2,
|
||
Maximize2,
|
||
Download,
|
||
Rotate3d,
|
||
AlertCircle,
|
||
Play,
|
||
Eye,
|
||
Save,
|
||
} from 'lucide-react';
|
||
import * as THREE from 'three';
|
||
import { DicomFusionVolume, MaskMapping, ModuleStyle, Project } from '../types';
|
||
import { api, downloadMask } from '../lib/api';
|
||
|
||
interface ModelPose {
|
||
rotateX: number;
|
||
rotateY: number;
|
||
rotateZ: number;
|
||
translateX: number;
|
||
translateY: number;
|
||
translateZ: number;
|
||
scale: number;
|
||
}
|
||
|
||
interface ModelPreviewPayload {
|
||
fileName: string;
|
||
triangleCount: number;
|
||
sampledTriangles: number;
|
||
vertices: number[];
|
||
bounds?: {
|
||
min: { x: number; y: number; z: number };
|
||
max: { x: number; y: number; z: number };
|
||
};
|
||
}
|
||
|
||
type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||
type DicomOpacityLevel = 'low' | 'medium' | 'high';
|
||
type ModelPoseKey = keyof ModelPose;
|
||
|
||
const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
|
||
{ id: 'standard', label: '标准', limit: 16000 },
|
||
{ id: 'fine', label: '精细', limit: 36000 },
|
||
{ id: 'ultra', label: '超精细', limit: 72000 },
|
||
{ id: 'solid', label: '实体', limit: 200000 },
|
||
];
|
||
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 },
|
||
{ id: 'medium', label: '中', sliceOpacity: 0.92, volumeOpacity: 0.2, boxOpacity: 0.42 },
|
||
{ id: 'high', label: '高', sliceOpacity: 1, volumeOpacity: 0.32, boxOpacity: 0.54 },
|
||
];
|
||
const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: number; minus: string; plus: string; quick?: number }> = {
|
||
rotateX: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
|
||
rotateY: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
|
||
rotateZ: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
|
||
translateX: { min: -2, max: 2, step: 0.05, minus: '-X', plus: '+X' },
|
||
translateY: { min: -2, max: 2, step: 0.05, minus: '-Y', plus: '+Y' },
|
||
translateZ: { min: -2, max: 2, step: 0.05, minus: '-Z', plus: '+Z' },
|
||
scale: { min: 0.5, max: 2, step: 0.05, minus: '-S', plus: '+S' },
|
||
};
|
||
|
||
const defaultModelPose: ModelPose = {
|
||
rotateX: 0,
|
||
rotateY: 0,
|
||
rotateZ: 0,
|
||
translateX: 0,
|
||
translateY: 0,
|
||
translateZ: 0,
|
||
scale: 1,
|
||
};
|
||
|
||
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||
|
||
function clamp(value: number, min: number, max: number) {
|
||
return Math.max(min, Math.min(max, value));
|
||
}
|
||
|
||
function createDicomTexture(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] = value > 4 ? 235 : 0;
|
||
}
|
||
context.putImageData(imageData, 0, 0);
|
||
|
||
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,
|
||
modelPose,
|
||
moduleStyles,
|
||
detailLimit,
|
||
solidMode,
|
||
dicomOpacity,
|
||
showBounds,
|
||
cutEnabled,
|
||
cutSlice,
|
||
}: {
|
||
project: Project;
|
||
volume: DicomFusionVolume | null;
|
||
modelPose: ModelPose;
|
||
moduleStyles: Record<string, ModuleStyle>;
|
||
detailLimit: number;
|
||
solidMode: boolean;
|
||
dicomOpacity: { sliceOpacity: number; volumeOpacity: number; boxOpacity: number };
|
||
showBounds: boolean;
|
||
cutEnabled: boolean;
|
||
cutSlice: number;
|
||
}) {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const modelPoseRef = useRef(modelPose);
|
||
const [status, setStatus] = useState('准备融合 DICOM 与 STL');
|
||
const [loadProgress, setLoadProgress] = useState(0);
|
||
|
||
useEffect(() => {
|
||
modelPoseRef.current = modelPose;
|
||
}, [modelPose]);
|
||
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container || !volume) return;
|
||
|
||
container.innerHTML = '';
|
||
setStatus('正在构建三维融合场景...');
|
||
setLoadProgress(8);
|
||
|
||
let disposed = false;
|
||
let animationId = 0;
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color('#030712');
|
||
|
||
const width = Math.max(container.clientWidth, 1);
|
||
const height = Math.max(container.clientHeight, 1);
|
||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.05, 1000);
|
||
camera.position.set(0, -6.2, 4.6);
|
||
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.72));
|
||
const keyLight = new THREE.DirectionalLight(0xffffff, 1.1);
|
||
keyLight.position.set(4, -5, 5);
|
||
scene.add(keyLight);
|
||
const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.55);
|
||
fillLight.position.set(-4, 3, 2);
|
||
scene.add(fillLight);
|
||
|
||
const fusionRoot = new THREE.Group();
|
||
const dicomGroup = new THREE.Group();
|
||
const modelPoseGroup = new THREE.Group();
|
||
const modelPivot = new THREE.Group();
|
||
modelPoseGroup.add(modelPivot);
|
||
fusionRoot.add(dicomGroup);
|
||
fusionRoot.add(modelPoseGroup);
|
||
scene.add(fusionRoot);
|
||
|
||
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
|
||
const baseExtent = 4.6;
|
||
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 planeGeometry = new THREE.PlaneGeometry(dicomWidth, dicomHeight);
|
||
|
||
const box = new THREE.Mesh(
|
||
new THREE.BoxGeometry(dicomWidth, dicomHeight, dicomDepth),
|
||
new THREE.MeshBasicMaterial({ color: '#020617', transparent: true, opacity: dicomOpacity.boxOpacity, depthWrite: false }),
|
||
);
|
||
dicomGroup.add(box);
|
||
const edges = new THREE.LineSegments(
|
||
new THREE.EdgesGeometry(box.geometry),
|
||
new THREE.LineBasicMaterial({ color: '#38bdf8', transparent: true, opacity: 0.46 }),
|
||
);
|
||
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,
|
||
}),
|
||
);
|
||
cutPlane.position.set(0, 0, cutZ);
|
||
cutPlane.visible = cutEnabled;
|
||
dicomGroup.add(cutPlane);
|
||
|
||
const textures: THREE.Texture[] = [];
|
||
volume.frames.forEach((frame, index) => {
|
||
const texture = createDicomTexture(frame, volume.width, volume.height);
|
||
if (!texture) return;
|
||
textures.push(texture);
|
||
const isLast = index === volume.frames.length - 1;
|
||
const material = new THREE.MeshBasicMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
opacity: isLast ? dicomOpacity.sliceOpacity : dicomOpacity.volumeOpacity,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false,
|
||
});
|
||
const slicePlane = new THREE.Mesh(planeGeometry, material);
|
||
const z = volume.frames.length <= 1
|
||
? 0
|
||
: -dicomDepth / 2 + (dicomDepth * index) / (volume.frames.length - 1);
|
||
slicePlane.position.set(0, 0, isLast ? dicomDepth / 2 + 0.006 : z);
|
||
dicomGroup.add(slicePlane);
|
||
});
|
||
setLoadProgress(42);
|
||
|
||
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
||
let modelBaseScale = 1;
|
||
let loadedModels = 0;
|
||
let failedModels = 0;
|
||
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||
|
||
Promise.allSettled(stlFiles.map((fileName, index) => (
|
||
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`)
|
||
.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: 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 ? [clippingPlane] : [],
|
||
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),
|
||
});
|
||
}
|
||
loadedModels += 1;
|
||
setLoadProgress(42 + Math.round(((loadedModels + failedModels) / Math.max(stlFiles.length, 1)) * 46));
|
||
})
|
||
))).then(() => {
|
||
if (disposed) 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();
|
||
}
|
||
});
|
||
const modelBounds = new THREE.LineSegments(
|
||
new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
|
||
new THREE.LineBasicMaterial({ color: '#facc15', transparent: true, opacity: 0.72 }),
|
||
);
|
||
modelBounds.visible = showBounds;
|
||
modelPivot.add(modelBounds);
|
||
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
|
||
modelPoseGroup.position.set(0, 0, 0);
|
||
modelPivot.position.set(0, 0, dicomDepth * 0.08);
|
||
setLoadProgress(100);
|
||
setStatus(stlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前项目没有 STL');
|
||
});
|
||
|
||
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.45, 2.2);
|
||
};
|
||
const preventContextMenu = (event: MouseEvent) => event.preventDefault();
|
||
|
||
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);
|
||
|
||
const handleResize = () => {
|
||
if (!container.clientWidth || !container.clientHeight) return;
|
||
camera.aspect = container.clientWidth / container.clientHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||
};
|
||
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 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 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);
|
||
textures.forEach((texture) => texture.dispose());
|
||
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,
|
||
solidMode,
|
||
dicomOpacity.sliceOpacity,
|
||
dicomOpacity.volumeOpacity,
|
||
dicomOpacity.boxOpacity,
|
||
showBounds,
|
||
cutEnabled,
|
||
cutSlice,
|
||
]);
|
||
|
||
return (
|
||
<div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
|
||
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
|
||
<div className="pointer-events-none absolute left-4 top-4 rounded-xl border border-white/10 bg-black/60 px-3 py-2 text-[10px] font-mono text-white/60">
|
||
{status}
|
||
</div>
|
||
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
|
||
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
|
||
</div>
|
||
{loadProgress < 100 && (
|
||
<div className="absolute inset-x-10 bottom-8 rounded-xl border border-white/10 bg-black/70 p-3">
|
||
<div className="mb-2 flex items-center justify-between text-[10px] font-bold text-white/70">
|
||
<span>正在融合三维影像与模型</span>
|
||
<span>{loadProgress}%</span>
|
||
</div>
|
||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||
<div className="h-full bg-blue-500 transition-all" style={{ width: `${loadProgress}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!volume && (
|
||
<div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-white/40">
|
||
正在载入 DICOM 三维体...
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||
const [sliceEnd, setSliceEnd] = useState(49);
|
||
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
||
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
|
||
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 },
|
||
{ id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
|
||
{ id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
|
||
]);
|
||
const [selectedPoseId, setSelectedPoseId] = useState('default');
|
||
const [isRegistering, setIsRegistering] = useState(false);
|
||
const [progress, setProgress] = useState(0);
|
||
const [project, setProject] = useState<Project | null>(null);
|
||
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
|
||
const [fusionError, setFusionError] = useState('');
|
||
const [exporting, setExporting] = useState(false);
|
||
const [exportMessage, setExportMessage] = useState('准备就绪');
|
||
const [preloadMessage, setPreloadMessage] = useState('缓存空闲');
|
||
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
|
||
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 = () => {
|
||
setIsRegistering(true);
|
||
setProgress(0);
|
||
};
|
||
|
||
const handleExport = async (format: 'nii' | 'nii.gz') => {
|
||
setExporting(true);
|
||
setExportMessage(`正在生成 ${format.toUpperCase()} 分割 Mask...`);
|
||
try {
|
||
await downloadMask(projectId, format);
|
||
setExportMessage(`${format.toUpperCase()} 分割 Mask 已生成并开始下载`);
|
||
} catch (err) {
|
||
setExportMessage(err instanceof Error ? err.message : '导出失败');
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
|
||
visible: fallback?.visible ?? true,
|
||
color: fallback?.color ?? moduleColors[index % moduleColors.length],
|
||
opacity: fallback?.opacity ?? 0.72,
|
||
partId: clamp(Math.round(fallback?.partId ?? index + 1), 1, 255),
|
||
});
|
||
|
||
const commitModuleStyles = (next: Record<string, ModuleStyle>) => {
|
||
setModuleStyles(next);
|
||
if (!project) {
|
||
return;
|
||
}
|
||
api.updateProjectModuleStyles(project.id, next)
|
||
.then((updated) => {
|
||
setProject(updated);
|
||
})
|
||
.catch(() => {
|
||
setFusionError('构件样式保存失败,请稍后重试');
|
||
});
|
||
};
|
||
|
||
const getFusionCacheKey = (projectIdValue: string, end: number, mode = 'soft') => `${projectIdValue}:${mode}:0:${end}`;
|
||
|
||
const loadFusionVolume = async (end: number, useCache = true) => {
|
||
if (!project?.dicomCount) return null;
|
||
const maxSliceValue = Math.max(project.dicomCount - 1, 0);
|
||
const safeEnd = clamp(end, 0, maxSliceValue);
|
||
const cacheKey = getFusionCacheKey(project.id, safeEnd);
|
||
const cached = fusionVolumeCacheRef.current.get(cacheKey);
|
||
if (useCache && cached) {
|
||
setFusionVolume(cached);
|
||
setPreloadMessage(`已使用缓存点位 ${safeEnd + 1}`);
|
||
return cached;
|
||
}
|
||
const volumePayload = await api.getDicomFusionVolume(project.id, 0, safeEnd, 'soft');
|
||
fusionVolumeCacheRef.current.set(cacheKey, volumePayload);
|
||
return volumePayload;
|
||
};
|
||
|
||
useEffect(() => {
|
||
api.getProject(projectId).then((item) => {
|
||
setProject(item);
|
||
const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0));
|
||
setSliceEnd(end);
|
||
setCutSlice(end);
|
||
setModelPose(defaultModelPose);
|
||
const nextStyles: Record<string, ModuleStyle> = {};
|
||
(item.stlFiles ?? []).forEach((fileName, index) => {
|
||
nextStyles[fileName] = makeDefaultModuleStyle(index, item.moduleStyles?.[fileName]);
|
||
});
|
||
setModuleStyles(nextStyles);
|
||
}).catch(() => {
|
||
setProject(null);
|
||
setFusionVolume(null);
|
||
});
|
||
}, [projectId]);
|
||
|
||
useEffect(() => {
|
||
if (!project?.dicomCount) return;
|
||
const maxSlice = Math.max(project.dicomCount - 1, 0);
|
||
const safeEnd = clamp(sliceEnd, 0, maxSlice);
|
||
const timer = window.setTimeout(() => {
|
||
setFusionError('');
|
||
loadFusionVolume(safeEnd)
|
||
.then(setFusionVolume)
|
||
.catch((error) => {
|
||
setFusionVolume(null);
|
||
setFusionError(error instanceof Error ? error.message : 'DICOM 融合体加载失败');
|
||
});
|
||
}, 180);
|
||
return () => window.clearTimeout(timer);
|
||
}, [project?.id, project?.dicomCount, sliceEnd]);
|
||
|
||
useEffect(() => () => {
|
||
if (poseRepeatRef.current.timeout !== null) {
|
||
window.clearTimeout(poseRepeatRef.current.timeout);
|
||
}
|
||
if (poseRepeatRef.current.interval !== null) {
|
||
window.clearInterval(poseRepeatRef.current.interval);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (isRegistering && progress < 100) {
|
||
const timer = setTimeout(() => setProgress((value) => value + 2), 50);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
if (progress >= 100) {
|
||
setIsRegistering(false);
|
||
}
|
||
return undefined;
|
||
}, [isRegistering, progress]);
|
||
|
||
const updateModelPose = (partial: Partial<ModelPose>) => {
|
||
setModelPose((current) => ({
|
||
...current,
|
||
...partial,
|
||
}));
|
||
setSelectedPoseId('custom');
|
||
};
|
||
|
||
const clampPoseValue = (key: ModelPoseKey, value: number) => {
|
||
const limit = poseStepConfig[key];
|
||
return clamp(value, limit.min, limit.max);
|
||
};
|
||
|
||
const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
|
||
setModelPose((current) => ({
|
||
...current,
|
||
[key]: clampPoseValue(key, current[key] + delta),
|
||
}));
|
||
setSelectedPoseId('custom');
|
||
};
|
||
|
||
const stopPoseRepeat = () => {
|
||
if (poseRepeatRef.current.timeout !== null) {
|
||
window.clearTimeout(poseRepeatRef.current.timeout);
|
||
poseRepeatRef.current.timeout = null;
|
||
}
|
||
if (poseRepeatRef.current.interval !== null) {
|
||
window.clearInterval(poseRepeatRef.current.interval);
|
||
poseRepeatRef.current.interval = null;
|
||
}
|
||
};
|
||
|
||
const startPoseRepeat = (key: ModelPoseKey, delta: number) => {
|
||
stopPoseRepeat();
|
||
poseRepeatRef.current.timeout = window.setTimeout(() => {
|
||
nudgeModelPose(key, delta);
|
||
poseRepeatRef.current.interval = window.setInterval(() => nudgeModelPose(key, delta), 90);
|
||
}, 360);
|
||
};
|
||
|
||
const resetRotationPose = () => {
|
||
setModelPose((current) => ({
|
||
...current,
|
||
rotateX: 0,
|
||
rotateY: 0,
|
||
rotateZ: 0,
|
||
}));
|
||
setSelectedPoseId('custom');
|
||
};
|
||
|
||
const resetTransformPose = () => {
|
||
setModelPose((current) => ({
|
||
...current,
|
||
translateX: 0,
|
||
translateY: 0,
|
||
translateZ: 0,
|
||
scale: 1,
|
||
}));
|
||
setSelectedPoseId('custom');
|
||
};
|
||
|
||
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
||
const stlFiles = project?.stlFiles ?? [];
|
||
const index = Math.max(0, stlFiles.indexOf(fileName));
|
||
const next = {
|
||
...moduleStyles,
|
||
[fileName]: makeDefaultModuleStyle(index, {
|
||
...(moduleStyles[fileName] ?? project?.moduleStyles?.[fileName]),
|
||
...partial,
|
||
}),
|
||
};
|
||
commitModuleStyles(next);
|
||
};
|
||
|
||
const updateModulePartId = (fileName: string, value: number) => {
|
||
updateModuleStyle(fileName, { partId: clamp(Math.round(Number.isFinite(value) ? value : 1), 1, 255) });
|
||
};
|
||
|
||
const saveCurrentPose = () => {
|
||
const nextPose = {
|
||
id: `pose-${Date.now()}`,
|
||
name: `位姿${savedPoses.length - 2}`,
|
||
pose: { ...modelPose },
|
||
};
|
||
setSavedPoses((current) => [...current, nextPose]);
|
||
setSelectedPoseId(nextPose.id);
|
||
};
|
||
|
||
const renamePose = (poseId: string, name: string) => {
|
||
if (poseId === 'default') return;
|
||
const nextName = name.trim();
|
||
setSavedPoses((current) => current.map((item) => (
|
||
item.id === poseId ? { ...item, name: nextName || item.name } : item
|
||
)));
|
||
};
|
||
|
||
const selectPose = (poseId: string) => {
|
||
const selected = savedPoses.find((item) => item.id === poseId);
|
||
if (!selected) return;
|
||
setSelectedPoseId(poseId);
|
||
setModelPose(selected.pose);
|
||
};
|
||
|
||
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
|
||
const displayStart = 0;
|
||
const displayEnd = clamp(sliceEnd, 0, maxSlice);
|
||
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
||
const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0];
|
||
const preloadPoints = [0.2, 0.4, 0.6, 0.8, 1].map((ratio) => clamp(Math.max(0, Math.round((project?.dicomCount ?? 1) * ratio) - 1), 0, maxSlice));
|
||
|
||
const preloadFusionPoint = async (end: number) => {
|
||
if (!project) return;
|
||
const safeEnd = clamp(end, 0, maxSlice);
|
||
setPreloadMessage(`正在预存第 ${safeEnd + 1} 张...`);
|
||
try {
|
||
await loadFusionVolume(safeEnd, false);
|
||
setPreloadMessage(`已预存第 ${safeEnd + 1} 张`);
|
||
} catch (error) {
|
||
setPreloadMessage(error instanceof Error ? error.message : '预存失败');
|
||
}
|
||
};
|
||
|
||
const preloadAllFusionPoints = async () => {
|
||
setPreloadMessage('正在预存五个点位...');
|
||
try {
|
||
await Promise.all(preloadPoints.map((point) => loadFusionVolume(point, true)));
|
||
setPreloadMessage('五个点位已预存');
|
||
} catch (error) {
|
||
setPreloadMessage(error instanceof Error ? error.message : '五点预存失败');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="h-full flex flex-col gap-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
{project && (
|
||
<div className="flex flex-wrap gap-3 text-sm font-bold">
|
||
<span className="rounded-xl bg-blue-50 px-4 py-2 text-blue-700">当前项目:{project.name}</span>
|
||
<span className="rounded-xl bg-slate-100 px-4 py-2 text-slate-700">DICOM {project.dicomCount}</span>
|
||
<span className="rounded-xl bg-slate-100 px-4 py-2 text-slate-700">STL {project.modelCount ?? 0}</span>
|
||
</div>
|
||
)}
|
||
{!project && <p className="text-sm text-slate-500">配准 DICOM 影像与三维模型,生成像素映射关系</p>}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handleStartRegistration}
|
||
disabled={isRegistering}
|
||
className="bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-indigo-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
|
||
>
|
||
{isRegistering ? (
|
||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||
) : <Dices size={18} />}
|
||
{isRegistering ? `正在自动配准 (${progress}%)` : '开始自动配准'}
|
||
</button>
|
||
<button
|
||
onClick={() => handleExport('nii.gz')}
|
||
disabled={exporting}
|
||
className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-emerald-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
|
||
>
|
||
<Download size={18} />
|
||
{exporting ? '正在导出' : '导出 NII.GZ'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 overflow-hidden">
|
||
<div className="lg:col-span-7 flex flex-col gap-4 overflow-hidden">
|
||
<div className="px-2 flex items-center justify-between shrink-0">
|
||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||
<Rotate3d size={18} className="text-blue-500" />
|
||
影像与模型融合视角
|
||
</h3>
|
||
<span className="text-[10px] font-mono text-slate-400">
|
||
Layer: {displayStart + 1}-{displayEnd + 1}/{project?.dicomCount ?? 0}
|
||
</span>
|
||
</div>
|
||
|
||
{project ? (
|
||
<FusionThreeView
|
||
project={project}
|
||
volume={fusionVolume}
|
||
modelPose={modelPose}
|
||
moduleStyles={moduleStyles}
|
||
detailLimit={selectedDisplay.limit}
|
||
solidMode={displayLevel === 'solid'}
|
||
dicomOpacity={selectedDicomOpacity}
|
||
showBounds={showBounds}
|
||
cutEnabled={cutEnabled}
|
||
cutSlice={cutSlice}
|
||
/>
|
||
) : (
|
||
<div className="flex-1 rounded-3xl border border-slate-100 bg-white flex items-center justify-center text-sm text-slate-400">
|
||
正在载入项目...
|
||
</div>
|
||
)}
|
||
|
||
{fusionError && (
|
||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-bold text-amber-700 flex items-center gap-2">
|
||
<AlertCircle size={16} />
|
||
{fusionError}
|
||
</div>
|
||
)}
|
||
|
||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<p className="text-xs font-bold text-slate-700">DICOM 切片范围</p>
|
||
<span className="text-[10px] font-mono text-blue-600">
|
||
{displayStart + 1} - {displayEnd + 1} / {project?.dicomCount ?? 0}
|
||
</span>
|
||
</div>
|
||
<label className="grid grid-cols-[76px_1fr_64px] items-center gap-3 text-[10px] font-bold text-slate-500">
|
||
显示范围
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max={maxSlice}
|
||
value={displayEnd}
|
||
onChange={(event) => setSliceEnd(Number(event.target.value))}
|
||
className="accent-blue-600"
|
||
/>
|
||
<span className="text-right font-mono">{displayEnd + 1} 张</span>
|
||
</label>
|
||
<p className="mt-3 text-[10px] leading-5 text-slate-400">
|
||
默认从第 1 张开始显示,滑条控制融合体使用到第几张切片。
|
||
</p>
|
||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||
{preloadPoints.map((point, index) => {
|
||
const cached = project ? fusionVolumeCacheRef.current.has(getFusionCacheKey(project.id, point)) : false;
|
||
return (
|
||
<button
|
||
key={`${point}-${index}`}
|
||
onClick={() => {
|
||
setSliceEnd(point);
|
||
preloadFusionPoint(point);
|
||
}}
|
||
className={`rounded-lg border px-2 py-1 text-[10px] font-bold transition-all ${
|
||
cached ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : 'border-slate-200 bg-white text-slate-500 hover:text-blue-600'
|
||
}`}
|
||
>
|
||
点位 {index + 1} · {point + 1}
|
||
</button>
|
||
);
|
||
})}
|
||
<button
|
||
onClick={preloadAllFusionPoints}
|
||
className="rounded-lg bg-blue-600 px-2 py-1 text-[10px] font-bold text-white hover:bg-blue-700"
|
||
>
|
||
预存五点
|
||
</button>
|
||
<span className="text-[10px] font-bold text-slate-400">{preloadMessage}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
|
||
<div className="px-2 shrink-0">
|
||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||
<Settings2 size={18} className="text-emerald-500" />
|
||
可视化工具栏
|
||
</h3>
|
||
</div>
|
||
|
||
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden flex flex-col p-4 gap-4">
|
||
<div className="flex-1 overflow-auto space-y-4 pr-1">
|
||
<div>
|
||
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">模型显示</p>
|
||
<div className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||
{displayOptions.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
onClick={() => setDisplayLevel(option.id)}
|
||
className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
|
||
displayLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||
}`}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">融合显示</p>
|
||
<div className="grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1">
|
||
{dicomOpacityOptions.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
onClick={() => setDicomOpacityLevel(option.id)}
|
||
className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
|
||
dicomOpacityLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||
}`}
|
||
>
|
||
DICOM {option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<label className="mt-2 flex items-center justify-between rounded-lg bg-slate-50 px-2 py-1.5 text-[10px] font-bold text-slate-500">
|
||
显示 DICOM/模型边界
|
||
<input
|
||
type="checkbox"
|
||
checked={showBounds}
|
||
onChange={(event) => setShowBounds(event.target.checked)}
|
||
className="accent-blue-600"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">模型切分</p>
|
||
<label className="flex items-center gap-1 text-[10px] font-bold text-slate-500">
|
||
启用
|
||
<input
|
||
type="checkbox"
|
||
checked={cutEnabled}
|
||
onChange={(event) => setCutEnabled(event.target.checked)}
|
||
className="accent-orange-500"
|
||
/>
|
||
</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>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">模型位姿</p>
|
||
<button onClick={saveCurrentPose} className="flex items-center gap-1 text-[10px] font-bold text-blue-600 hover:text-blue-700">
|
||
<Save size={12} />
|
||
保存
|
||
</button>
|
||
</div>
|
||
<select
|
||
value={selectedPoseId}
|
||
onChange={(event) => selectPose(event.target.value)}
|
||
className="mb-2 h-8 w-full rounded-lg border border-slate-200 bg-white px-2 text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400"
|
||
>
|
||
{selectedPoseId === 'custom' && <option value="custom">当前未保存位姿</option>}
|
||
{savedPoses.map((item) => (
|
||
<option key={item.id} value={item.id}>{item.name}</option>
|
||
))}
|
||
</select>
|
||
{selectedPoseId !== 'default' && selectedPoseId !== 'custom' && (
|
||
<input
|
||
value={savedPoses.find((item) => item.id === selectedPoseId)?.name ?? ''}
|
||
onChange={(event) => renamePose(selectedPoseId, event.target.value)}
|
||
className="mb-2 h-8 w-full rounded-lg border border-slate-200 bg-white px-2 text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400"
|
||
placeholder="位姿名称"
|
||
/>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<button
|
||
onClick={resetRotationPose}
|
||
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
|
||
>
|
||
重置旋转位姿
|
||
</button>
|
||
<button
|
||
onClick={resetTransformPose}
|
||
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
|
||
>
|
||
重置平移缩放位姿
|
||
</button>
|
||
</div>
|
||
<div className="mt-3 space-y-2">
|
||
{[
|
||
{ key: 'rotateX' as const, label: '旋转 X', value: modelPose.rotateX },
|
||
{ key: 'rotateY' as const, label: '旋转 Y', value: modelPose.rotateY },
|
||
{ key: 'rotateZ' as const, label: '旋转 Z', value: modelPose.rotateZ },
|
||
{ key: 'translateX' as const, label: '平移 X', value: modelPose.translateX },
|
||
{ key: 'translateY' as const, label: '平移 Y', value: modelPose.translateY },
|
||
{ key: 'translateZ' as const, label: '平移 Z', value: modelPose.translateZ },
|
||
{ key: 'scale' as const, label: '缩放', value: modelPose.scale },
|
||
].map((item) => (
|
||
<div key={item.key} className="grid grid-cols-[44px_32px_1fr_32px_34px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
||
<span>{item.label}</span>
|
||
<button
|
||
onMouseDown={() => startPoseRepeat(item.key, -poseStepConfig[item.key].step)}
|
||
onMouseUp={stopPoseRepeat}
|
||
onMouseLeave={stopPoseRepeat}
|
||
onTouchStart={(event) => {
|
||
event.preventDefault();
|
||
startPoseRepeat(item.key, -poseStepConfig[item.key].step);
|
||
}}
|
||
onTouchEnd={stopPoseRepeat}
|
||
onClick={() => nudgeModelPose(item.key, -poseStepConfig[item.key].step)}
|
||
className="h-6 rounded-md bg-white text-[9px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
|
||
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].minus}`}
|
||
>
|
||
-
|
||
</button>
|
||
<input
|
||
type="range"
|
||
min={poseStepConfig[item.key].min}
|
||
max={poseStepConfig[item.key].max}
|
||
step={poseStepConfig[item.key].step}
|
||
value={item.value}
|
||
onChange={(event) => updateModelPose({ [item.key]: Number(event.target.value) })}
|
||
className="accent-blue-600"
|
||
/>
|
||
<button
|
||
onMouseDown={() => startPoseRepeat(item.key, poseStepConfig[item.key].step)}
|
||
onMouseUp={stopPoseRepeat}
|
||
onMouseLeave={stopPoseRepeat}
|
||
onTouchStart={(event) => {
|
||
event.preventDefault();
|
||
startPoseRepeat(item.key, poseStepConfig[item.key].step);
|
||
}}
|
||
onTouchEnd={stopPoseRepeat}
|
||
onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].step)}
|
||
className="h-6 rounded-md bg-white text-[9px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
|
||
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].plus}`}
|
||
>
|
||
+
|
||
</button>
|
||
<span className="text-right font-mono">{Number(item.value).toFixed(poseStepConfig[item.key].step < 1 ? 2 : 0)}</span>
|
||
{poseStepConfig[item.key].quick && (
|
||
<div className="col-start-2 col-span-3 grid grid-cols-2 gap-1">
|
||
<button
|
||
onClick={() => nudgeModelPose(item.key, -(poseStepConfig[item.key].quick ?? 0))}
|
||
className="h-6 rounded-md bg-slate-50 text-[9px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
|
||
>
|
||
-90°
|
||
</button>
|
||
<button
|
||
onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].quick ?? 0)}
|
||
className="h-6 rounded-md bg-slate-50 text-[9px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
|
||
>
|
||
+90°
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">构件层级</p>
|
||
<span className="text-[10px] font-mono text-slate-400">{project?.stlFiles?.length ?? 0}</span>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{(project?.stlFiles ?? []).map((fileName, index) => {
|
||
const style = moduleStyles[fileName] ?? {
|
||
visible: true,
|
||
color: moduleColors[index % moduleColors.length],
|
||
opacity: 0.72,
|
||
partId: index + 1,
|
||
};
|
||
return (
|
||
<div key={fileName} className={`rounded-xl bg-slate-50 p-2 ${!style.visible ? 'opacity-50' : ''}`}>
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<input
|
||
type="color"
|
||
value={style.color}
|
||
onChange={(event) => updateModuleStyle(fileName, { color: event.target.value })}
|
||
className="h-7 w-7 shrink-0 rounded border border-white bg-white p-0.5"
|
||
title="模型颜色"
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-[10px] font-bold text-slate-700">{fileName.replace(/\.stl$/i, '')}</p>
|
||
<label className="mt-1 flex items-center gap-1 text-[9px] font-bold text-slate-400">
|
||
ID
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="255"
|
||
value={style.partId}
|
||
onChange={(event) => updateModulePartId(fileName, Number(event.target.value))}
|
||
className="h-5 w-12 rounded border border-slate-200 bg-white px-1 font-mono text-slate-600"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<button
|
||
onClick={() => updateModuleStyle(fileName, { visible: !style.visible })}
|
||
className={`rounded p-1 ${style.visible ? 'text-blue-500' : 'text-slate-300'} hover:bg-white`}
|
||
title={style.visible ? '隐藏构件' : '显示构件'}
|
||
>
|
||
<Eye size={14} />
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[9px] text-slate-400">透明度</span>
|
||
<input
|
||
type="range"
|
||
min="0.1"
|
||
max="1"
|
||
step="0.05"
|
||
value={style.opacity}
|
||
onChange={(event) => updateModuleStyle(fileName, { opacity: Number(event.target.value) })}
|
||
className="min-w-0 flex-1 accent-blue-600"
|
||
/>
|
||
<span className="w-7 text-right text-[9px] text-slate-400">{Math.round(style.opacity * 100)}%</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="lg:col-span-3 flex flex-col gap-4 overflow-hidden">
|
||
<div className="px-2 flex items-center justify-between shrink-0">
|
||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||
<Play size={18} className="text-blue-500" />
|
||
Mask 展示
|
||
</h3>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => handleExport('nii')}
|
||
disabled={exporting}
|
||
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border border-slate-200 flex items-center gap-1 disabled:opacity-50"
|
||
>
|
||
<Download size={12} />
|
||
NII
|
||
</button>
|
||
<button
|
||
onClick={() => handleExport('nii.gz')}
|
||
disabled={exporting}
|
||
className="bg-slate-900 hover:bg-black text-white px-3 py-1 rounded-lg text-[10px] font-bold transition-all flex items-center gap-1 shadow-lg disabled:opacity-50"
|
||
>
|
||
<Download size={12} />
|
||
NII.GZ
|
||
</button>
|
||
</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">
|
||
<div className="relative w-64 h-64">
|
||
<div className="absolute inset-0 opacity-10 blur-xl bg-white rounded-full translate-x-4 translate-y-4" />
|
||
{mappings.map((mapping, index) => (
|
||
<motion.div
|
||
key={mapping.maskId}
|
||
initial={{ scale: 0.8, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 0.8 }}
|
||
transition={{ delay: index * 0.2 }}
|
||
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>
|
||
);
|
||
}
|