2026-05-04-05-41-22 增强3D实体预览和位姿控制

This commit is contained in:
2026-05-04 05:50:06 +08:00
parent 4922c2d991
commit 1cc750b7e4
7 changed files with 444 additions and 24 deletions

View File

@@ -545,7 +545,7 @@ function createStlPreview(filePath: string, fileName: string, limit: number) {
throw new Error('当前仅支持二进制 STL 预览');
}
const sampleLimit = Math.max(100, Math.min(limit, 12000));
const sampleLimit = Math.max(100, Math.min(limit, 36000));
const step = Math.max(1, Math.ceil(triangleCount / sampleLimit));
const vertices: number[] = [];
let sampledTriangles = 0;

View File

@@ -25,6 +25,7 @@ import { api, downloadDicomArchive, downloadMask } from '../lib/api';
type Plane = 'axial' | 'sagittal' | 'coronal';
type DisplayMode = DicomPreview['mode'];
type SolidityLevel = 'preview' | 'standard' | 'fine';
interface ModuleStyle {
visible: boolean;
@@ -32,6 +33,17 @@ interface ModuleStyle {
opacity: number;
}
interface ModelPose {
rotateX: number;
rotateY: number;
rotateZ: number;
translateX: number;
translateY: number;
translateZ: number;
scale: number;
autoRotate: boolean;
}
interface ModelPreviewPayload {
fileName: string;
triangleCount: number;
@@ -40,6 +52,21 @@ interface ModelPreviewPayload {
}
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [
{ id: 'preview', label: '预览', limit: 6000 },
{ id: 'standard', label: '标准', limit: 16000 },
{ id: 'fine', label: '精细', limit: 36000 },
];
const defaultModelPose: ModelPose = {
rotateX: 0,
rotateY: 0,
rotateZ: 0,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
autoRotate: true,
};
function drawFallbackModelPreview(
canvas: HTMLCanvasElement,
@@ -177,15 +204,26 @@ function NativeStlViewer({
projectId,
files,
styles,
detailLimit,
solidWhite,
pose,
}: {
projectId: string;
files: string[];
styles: Record<string, ModuleStyle>;
detailLimit: number;
solidWhite: boolean;
pose: ModelPose;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const poseRef = useRef<ModelPose>(pose);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('准备加载模型');
useEffect(() => {
poseRef.current = pose;
}, [pose]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@@ -225,7 +263,10 @@ function NativeStlViewer({
})
.then((payload) => ({
payload,
style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true },
style: {
...(styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true }),
color: solidWhite ? '#f4f4f2' : styles[fileName]?.color ?? '#3b82f6',
},
})),
),
).then((results) => {
@@ -266,12 +307,14 @@ function NativeStlViewer({
scene.add(fillLight);
const group = new THREE.Group();
let baseScale = 1;
let autoSpin = 0;
scene.add(group);
let loaded = 0;
let failed = 0;
visibleFiles.forEach((fileName) => {
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=6000`)
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`)
.then((response) => {
if (!response.ok) {
throw new Error('模型预览数据加载失败');
@@ -287,11 +330,11 @@ function NativeStlViewer({
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({
color: style.color,
color: solidWhite ? '#f4f4f2' : style.color,
opacity: style.opacity,
transparent: style.opacity < 1,
roughness: 0.48,
metalness: 0.08,
roughness: solidWhite ? 0.34 : 0.48,
metalness: solidWhite ? 0.02 : 0.08,
side: THREE.DoubleSide,
}),
);
@@ -313,7 +356,8 @@ function NativeStlViewer({
}
});
group.position.set(0, 0, 0);
group.scale.setScalar(4.2 / maxSize);
baseScale = 4.2 / maxSize;
group.scale.setScalar(baseScale * poseRef.current.scale);
camera.lookAt(0, 0, 0);
setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成');
}
@@ -336,7 +380,17 @@ function NativeStlViewer({
const animate = () => {
if (disposed) return;
group.rotation.y += 0.004;
const currentPose = poseRef.current;
if (currentPose.autoRotate) {
autoSpin += 0.004;
}
group.rotation.set(
THREE.MathUtils.degToRad(currentPose.rotateX),
THREE.MathUtils.degToRad(currentPose.rotateY) + autoSpin,
THREE.MathUtils.degToRad(currentPose.rotateZ),
);
group.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ);
group.scale.setScalar(baseScale * currentPose.scale);
renderer.render(scene, camera);
animationId = window.requestAnimationFrame(animate);
};
@@ -360,7 +414,7 @@ function NativeStlViewer({
});
container.innerHTML = '';
};
}, [projectId, files.join('|'), JSON.stringify(styles)]);
}, [projectId, files.join('|'), JSON.stringify(styles), detailLimit, solidWhite]);
return (
<div className="h-full w-full relative">
@@ -396,6 +450,10 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
const [plane, setPlane] = useState<Plane>('axial');
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
const [rotation, setRotation] = useState(0);
const [isSliceChanging, setIsSliceChanging] = useState(false);
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
const [solidWhite, setSolidWhite] = useState(true);
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [dicomError, setDicomError] = useState('');
@@ -406,6 +464,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
const [editingName, setEditingName] = useState('');
const [actionMessage, setActionMessage] = useState('');
const sliceRepeatRef = useRef<number | null>(null);
const dicomRequestRef = useRef(0);
const refreshProjects = () => {
setLoading(true);
@@ -448,6 +507,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
];
const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false);
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[1];
useEffect(() => {
const next: Record<string, ModuleStyle> = {};
@@ -460,26 +520,33 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
});
setModuleStyles(next);
setSliceIndex(0);
setModelPose(defaultModelPose);
}, [selectedProject?.id]);
useEffect(() => {
if (!selectedProject || viewMode !== 'dicom' || !selectedProject.dicomCount) {
setDicomPreview(null);
setIsSliceChanging(false);
return;
}
let cancelled = false;
const requestId = dicomRequestRef.current + 1;
dicomRequestRef.current = requestId;
setDicomError('');
setIsSliceChanging(true);
api.getDicomPreview(selectedProject.id, sliceIndex, plane, displayMode)
.then((preview) => {
if (!cancelled) {
if (!cancelled && requestId === dicomRequestRef.current) {
setDicomPreview(preview);
setIsSliceChanging(false);
}
})
.catch((error) => {
if (!cancelled) {
if (!cancelled && requestId === dicomRequestRef.current) {
setDicomPreview(null);
setDicomError(error instanceof Error ? error.message : 'DICOM 预览失败');
setIsSliceChanging(false);
}
});
@@ -546,7 +613,19 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
const startSliceStep = (delta: number) => {
stopSliceStep();
stepSlice(delta);
sliceRepeatRef.current = window.setInterval(() => stepSlice(delta), 110);
sliceRepeatRef.current = window.setInterval(() => stepSlice(delta), 95);
};
const updateModelPose = (partial: Partial<ModelPose>) => {
setModelPose((current) => ({
...current,
autoRotate: partial.autoRotate ?? false,
...partial,
}));
};
const resetModelPose = () => {
setModelPose(defaultModelPose);
};
const rotateDicom = (delta: number) => {
@@ -837,12 +916,17 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
<p>SCAN DATE: {selectedProject.createTime}</p>
<p>DICOM PATH: {selectedProject.dicomPath}</p>
</div>
<div className="relative w-full h-full flex items-center justify-center">
<div className={`relative w-full h-full flex items-center justify-center transition-all duration-150 ${isSliceChanging ? 'scale-[1.01] opacity-85 brightness-110' : 'scale-100 opacity-100 brightness-100'}`}>
{dicomPreview ? (
<DicomCanvas preview={dicomPreview} rotation={rotation} />
) : (
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">{dicomError || '正在解析 DICOM 像素...'}</p>
)}
{isSliceChanging && dicomPreview && (
<span className="absolute right-3 top-3 rounded-md bg-blue-500/20 px-2 py-1 text-[9px] font-bold text-blue-200 backdrop-blur-sm">
</span>
)}
</div>
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
@@ -917,13 +1001,92 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
<div className="h-full flex gap-8">
{/* Left: 3D Visualization */}
<div className="flex-1 bg-slate-50 rounded-2xl relative border border-slate-100 overflow-hidden">
<NativeStlViewer projectId={selectedProject.id} files={stlFiles} styles={moduleStyles} />
<NativeStlViewer
projectId={selectedProject.id}
files={stlFiles}
styles={moduleStyles}
detailLimit={selectedSolidity.limit}
solidWhite={solidWhite}
pose={modelPose}
/>
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0}
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label}
</div>
</div>
{/* Right: Sub-module List */}
<div className="w-64 h-full flex flex-col overflow-hidden">
<div className="w-80 h-full flex flex-col overflow-hidden">
<div className="shrink-0 space-y-4 pb-4">
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-bold text-slate-700"></p>
<button
onClick={resetModelPose}
className="text-[10px] font-bold text-blue-600 hover:text-blue-700"
>
姿
</button>
</div>
<div className="grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1 mb-3">
{solidityOptions.map((option) => (
<button
key={option.id}
onClick={() => setSolidityLevel(option.id)}
className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
solidityLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
{option.label}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setSolidWhite((current) => !current)}
className={`rounded-xl px-3 py-2 text-[10px] font-bold border transition-all ${
solidWhite ? 'bg-white text-slate-800 border-slate-200 shadow-sm' : 'bg-transparent text-slate-500 border-slate-200'
}`}
>
</button>
<button
onClick={() => updateModelPose({ autoRotate: !modelPose.autoRotate })}
className={`rounded-xl px-3 py-2 text-[10px] font-bold border transition-all ${
modelPose.autoRotate ? 'bg-blue-600 text-white border-blue-600 shadow-sm' : 'bg-transparent text-slate-500 border-slate-200'
}`}
>
</button>
</div>
</div>
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
<p className="text-xs font-bold text-slate-700">姿</p>
{[
{ key: 'rotateX', label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX },
{ key: 'rotateY', label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY },
{ key: 'rotateZ', label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ },
{ key: 'translateX', label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX },
{ key: 'translateY', label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY },
{ key: 'translateZ', label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ },
{ key: 'scale', label: '缩放', min: 0.5, max: 2, step: 0.05, value: modelPose.scale },
].map((item) => (
<div key={item.key} className="grid grid-cols-[48px_1fr_42px] items-center gap-2">
<span className="text-[10px] font-bold text-slate-500">{item.label}</span>
<input
type="range"
min={item.min}
max={item.max}
step={item.step}
value={item.value}
onChange={(event) => updateModelPose({ [item.key]: Number(event.target.value) } as Partial<ModelPose>)}
className="w-full accent-blue-600"
/>
<span className="text-[10px] font-mono text-slate-400 text-right">{Number(item.value).toFixed(item.step < 1 ? 2 : 0)}</span>
</div>
))}
</div>
</div>
<div className="px-1 flex items-center justify-between mb-3 shrink-0">
<p className="text-xs font-bold text-slate-700 uppercase tracking-widest"> ({stlFiles.length})</p>
<button

View File

@@ -115,17 +115,14 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
<div className="h-full flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-slate-800"></h2>
<p className="text-slate-500 mt-1">
{project ? `当前项目:${project.name}` : '配准 DICOM 影像与三维模型,生成像素映射关系'}
</p>
{project && (
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-bold">
<span className="rounded-lg bg-blue-50 px-3 py-1 text-blue-700">{project.name}</span>
<span className="rounded-lg bg-slate-100 px-3 py-1 text-slate-600">DICOM {project.dicomCount}</span>
<span className="rounded-lg bg-slate-100 px-3 py-1 text-slate-600">STL {project.modelCount ?? 0}</span>
<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