2026-05-04-05-41-22 增强3D实体预览和位姿控制
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user