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 预览');
|
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 step = Math.max(1, Math.ceil(triangleCount / sampleLimit));
|
||||||
const vertices: number[] = [];
|
const vertices: number[] = [];
|
||||||
let sampledTriangles = 0;
|
let sampledTriangles = 0;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { api, downloadDicomArchive, downloadMask } from '../lib/api';
|
|||||||
|
|
||||||
type Plane = 'axial' | 'sagittal' | 'coronal';
|
type Plane = 'axial' | 'sagittal' | 'coronal';
|
||||||
type DisplayMode = DicomPreview['mode'];
|
type DisplayMode = DicomPreview['mode'];
|
||||||
|
type SolidityLevel = 'preview' | 'standard' | 'fine';
|
||||||
|
|
||||||
interface ModuleStyle {
|
interface ModuleStyle {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -32,6 +33,17 @@ interface ModuleStyle {
|
|||||||
opacity: number;
|
opacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ModelPose {
|
||||||
|
rotateX: number;
|
||||||
|
rotateY: number;
|
||||||
|
rotateZ: number;
|
||||||
|
translateX: number;
|
||||||
|
translateY: number;
|
||||||
|
translateZ: number;
|
||||||
|
scale: number;
|
||||||
|
autoRotate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ModelPreviewPayload {
|
interface ModelPreviewPayload {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
triangleCount: number;
|
triangleCount: number;
|
||||||
@@ -40,6 +52,21 @@ interface ModelPreviewPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
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(
|
function drawFallbackModelPreview(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
@@ -177,15 +204,26 @@ function NativeStlViewer({
|
|||||||
projectId,
|
projectId,
|
||||||
files,
|
files,
|
||||||
styles,
|
styles,
|
||||||
|
detailLimit,
|
||||||
|
solidWhite,
|
||||||
|
pose,
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
files: string[];
|
files: string[];
|
||||||
styles: Record<string, ModuleStyle>;
|
styles: Record<string, ModuleStyle>;
|
||||||
|
detailLimit: number;
|
||||||
|
solidWhite: boolean;
|
||||||
|
pose: ModelPose;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const poseRef = useRef<ModelPose>(pose);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [status, setStatus] = useState('准备加载模型');
|
const [status, setStatus] = useState('准备加载模型');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
poseRef.current = pose;
|
||||||
|
}, [pose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -225,7 +263,10 @@ function NativeStlViewer({
|
|||||||
})
|
})
|
||||||
.then((payload) => ({
|
.then((payload) => ({
|
||||||
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) => {
|
).then((results) => {
|
||||||
@@ -266,12 +307,14 @@ function NativeStlViewer({
|
|||||||
scene.add(fillLight);
|
scene.add(fillLight);
|
||||||
|
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
|
let baseScale = 1;
|
||||||
|
let autoSpin = 0;
|
||||||
scene.add(group);
|
scene.add(group);
|
||||||
let loaded = 0;
|
let loaded = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
visibleFiles.forEach((fileName) => {
|
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) => {
|
.then((response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('模型预览数据加载失败');
|
throw new Error('模型预览数据加载失败');
|
||||||
@@ -287,11 +330,11 @@ function NativeStlViewer({
|
|||||||
const mesh = new THREE.Mesh(
|
const mesh = new THREE.Mesh(
|
||||||
geometry,
|
geometry,
|
||||||
new THREE.MeshStandardMaterial({
|
new THREE.MeshStandardMaterial({
|
||||||
color: style.color,
|
color: solidWhite ? '#f4f4f2' : style.color,
|
||||||
opacity: style.opacity,
|
opacity: style.opacity,
|
||||||
transparent: style.opacity < 1,
|
transparent: style.opacity < 1,
|
||||||
roughness: 0.48,
|
roughness: solidWhite ? 0.34 : 0.48,
|
||||||
metalness: 0.08,
|
metalness: solidWhite ? 0.02 : 0.08,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -313,7 +356,8 @@ function NativeStlViewer({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
group.position.set(0, 0, 0);
|
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);
|
camera.lookAt(0, 0, 0);
|
||||||
setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成');
|
setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成');
|
||||||
}
|
}
|
||||||
@@ -336,7 +380,17 @@ function NativeStlViewer({
|
|||||||
|
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
if (disposed) return;
|
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);
|
renderer.render(scene, camera);
|
||||||
animationId = window.requestAnimationFrame(animate);
|
animationId = window.requestAnimationFrame(animate);
|
||||||
};
|
};
|
||||||
@@ -360,7 +414,7 @@ function NativeStlViewer({
|
|||||||
});
|
});
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
};
|
};
|
||||||
}, [projectId, files.join('|'), JSON.stringify(styles)]);
|
}, [projectId, files.join('|'), JSON.stringify(styles), detailLimit, solidWhite]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full relative">
|
<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 [plane, setPlane] = useState<Plane>('axial');
|
||||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
|
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
|
||||||
const [rotation, setRotation] = useState(0);
|
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 [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
|
||||||
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
||||||
const [dicomError, setDicomError] = useState('');
|
const [dicomError, setDicomError] = useState('');
|
||||||
@@ -406,6 +464,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
const [editingName, setEditingName] = useState('');
|
const [editingName, setEditingName] = useState('');
|
||||||
const [actionMessage, setActionMessage] = useState('');
|
const [actionMessage, setActionMessage] = useState('');
|
||||||
const sliceRepeatRef = useRef<number | null>(null);
|
const sliceRepeatRef = useRef<number | null>(null);
|
||||||
|
const dicomRequestRef = useRef(0);
|
||||||
|
|
||||||
const refreshProjects = () => {
|
const refreshProjects = () => {
|
||||||
setLoading(true);
|
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 allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false);
|
||||||
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
|
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
|
||||||
|
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[1];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const next: Record<string, ModuleStyle> = {};
|
const next: Record<string, ModuleStyle> = {};
|
||||||
@@ -460,26 +520,33 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
});
|
});
|
||||||
setModuleStyles(next);
|
setModuleStyles(next);
|
||||||
setSliceIndex(0);
|
setSliceIndex(0);
|
||||||
|
setModelPose(defaultModelPose);
|
||||||
}, [selectedProject?.id]);
|
}, [selectedProject?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProject || viewMode !== 'dicom' || !selectedProject.dicomCount) {
|
if (!selectedProject || viewMode !== 'dicom' || !selectedProject.dicomCount) {
|
||||||
setDicomPreview(null);
|
setDicomPreview(null);
|
||||||
|
setIsSliceChanging(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const requestId = dicomRequestRef.current + 1;
|
||||||
|
dicomRequestRef.current = requestId;
|
||||||
setDicomError('');
|
setDicomError('');
|
||||||
|
setIsSliceChanging(true);
|
||||||
api.getDicomPreview(selectedProject.id, sliceIndex, plane, displayMode)
|
api.getDicomPreview(selectedProject.id, sliceIndex, plane, displayMode)
|
||||||
.then((preview) => {
|
.then((preview) => {
|
||||||
if (!cancelled) {
|
if (!cancelled && requestId === dicomRequestRef.current) {
|
||||||
setDicomPreview(preview);
|
setDicomPreview(preview);
|
||||||
|
setIsSliceChanging(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!cancelled) {
|
if (!cancelled && requestId === dicomRequestRef.current) {
|
||||||
setDicomPreview(null);
|
setDicomPreview(null);
|
||||||
setDicomError(error instanceof Error ? error.message : 'DICOM 预览失败');
|
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) => {
|
const startSliceStep = (delta: number) => {
|
||||||
stopSliceStep();
|
stopSliceStep();
|
||||||
stepSlice(delta);
|
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) => {
|
const rotateDicom = (delta: number) => {
|
||||||
@@ -837,12 +916,17 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
<p>SCAN DATE: {selectedProject.createTime}</p>
|
<p>SCAN DATE: {selectedProject.createTime}</p>
|
||||||
<p>DICOM PATH: {selectedProject.dicomPath}</p>
|
<p>DICOM PATH: {selectedProject.dicomPath}</p>
|
||||||
</div>
|
</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 ? (
|
{dicomPreview ? (
|
||||||
<DicomCanvas preview={dicomPreview} rotation={rotation} />
|
<DicomCanvas preview={dicomPreview} rotation={rotation} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">{dicomError || '正在解析 DICOM 像素...'}</p>
|
<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>
|
||||||
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
|
<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>
|
<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">
|
<div className="h-full flex gap-8">
|
||||||
{/* Left: 3D Visualization */}
|
{/* Left: 3D Visualization */}
|
||||||
<div className="flex-1 bg-slate-50 rounded-2xl relative border border-slate-100 overflow-hidden">
|
<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]">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/* Right: Sub-module List */}
|
{/* 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">
|
<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>
|
<p className="text-xs font-bold text-slate-700 uppercase tracking-widest">构件层级 ({stlFiles.length})</p>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -115,17 +115,14 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
<div className="h-full flex flex-col gap-6">
|
<div className="h-full flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-slate-800">逆向工作区</h2>
|
|
||||||
<p className="text-slate-500 mt-1">
|
|
||||||
{project ? `当前项目:${project.name}` : '配准 DICOM 影像与三维模型,生成像素映射关系'}
|
|
||||||
</p>
|
|
||||||
{project && (
|
{project && (
|
||||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px] font-bold">
|
<div className="flex flex-wrap gap-3 text-sm font-bold">
|
||||||
<span className="rounded-lg bg-blue-50 px-3 py-1 text-blue-700">当前项目:{project.name}</span>
|
<span className="rounded-xl bg-blue-50 px-4 py-2 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-xl bg-slate-100 px-4 py-2 text-slate-700">DICOM {project.dicomCount}</span>
|
||||||
<span className="rounded-lg bg-slate-100 px-3 py-1 text-slate-600">STL {project.modelCount ?? 0}</span>
|
<span className="rounded-xl bg-slate-100 px-4 py-2 text-slate-700">STL {project.modelCount ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!project && <p className="text-sm text-slate-500">配准 DICOM 影像与三维模型,生成像素映射关系</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
82
工程分析/实现方案-2026-05-04-05-41-22.md
Normal file
82
工程分析/实现方案-2026-05-04-05-41-22.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 实现方案 - 2026-05-04-05-41-22
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
在不直接加载全部原始 STL 的前提下,提高 3D 模型预览的实体感,并加入整体位姿控制;改进 DICOM 长按切片时的连续图像反馈;精简逆向工作区重复标题与项目描述。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `WebSite/server.ts`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
### 1. 3D 模型实体化程度
|
||||||
|
|
||||||
|
- 在项目库 3D 模型页增加“实体化程度”控制,建议分为:
|
||||||
|
- `预览`:维持较低三角面抽样,优先速度。
|
||||||
|
- `标准`:提高抽样数量,呈现连续表面。
|
||||||
|
- `精细`:进一步提高抽样数量,但仍保留上限防止卡顿。
|
||||||
|
- 将当前固定 `/preview?limit=6000` 改为根据实体化程度动态请求,例如 `6000 / 16000 / 36000`。
|
||||||
|
- Three.js 渲染继续使用 `MeshStandardMaterial`,但新增:
|
||||||
|
- `flatShading: false`
|
||||||
|
- 更强的环境光、主光和边缘光。
|
||||||
|
- 可选整体白色实体显示模式,让模型更接近用户参考图。
|
||||||
|
- 保留每个 STL 构件的颜色和透明度控制,新增“整体白色实体”开关时临时覆盖颜色为白/灰,不破坏原配置。
|
||||||
|
|
||||||
|
### 2. 3D 模型位姿控制
|
||||||
|
|
||||||
|
- 在 3D 模型左侧画布上方或右侧面板增加整体位姿控制:
|
||||||
|
- 旋转 X/Y/Z 滑块,范围 `-180° ~ 180°`。
|
||||||
|
- 平移 X/Y/Z 微调,范围建议 `-2 ~ 2`。
|
||||||
|
- 缩放滑块,范围建议 `0.5 ~ 2`。
|
||||||
|
- 重置按钮。
|
||||||
|
- 将自动旋转改为可开关;当用户调整位姿时,自动旋转默认关闭,避免控制冲突。
|
||||||
|
- 将位姿状态传入 `NativeStlViewer`,渲染循环中应用到 group。
|
||||||
|
|
||||||
|
### 3. DICOM 长按连续图像变化
|
||||||
|
|
||||||
|
- 当前 `startSliceStep` 只是连续改变 `sliceIndex`,图像依赖 API 响应更新。为增强“动起来”的感觉:
|
||||||
|
- 将步进间隔保持在 `90~120ms`,同时在请求期间显示轻量切片过渡效果。
|
||||||
|
- 增加 `latestPreviewRequestRef` 或请求序列号,确保旧请求返回时不会覆盖新切片。
|
||||||
|
- 在 `sliceIndex` 变化时,立即更新右下角切片文字和 canvas 容器的轻微淡入/亮度变化。
|
||||||
|
- 若接口响应较慢,保留上一帧图像并叠加“正在切换”状态,避免画面停滞误解。
|
||||||
|
|
||||||
|
### 4. 逆向工作区信息精简
|
||||||
|
|
||||||
|
- 删除 `ReverseWorkspace` 页面内 `h2` 标题“逆向工作区”,保留全局 header 的标题。
|
||||||
|
- 删除页面内副标题中的上方“当前项目:xxx”文本。
|
||||||
|
- 保留下方标签行:
|
||||||
|
- `当前项目:头部 CT 模型逆向体素化演示`
|
||||||
|
- `DICOM 300`
|
||||||
|
- `STL 9`
|
||||||
|
- 将标签字号从 `text-[11px]` 提升为 `text-sm` 或 `text-[13px]`,内边距加大,提高可读性。
|
||||||
|
|
||||||
|
## 数据流或交互流程
|
||||||
|
|
||||||
|
1. 用户进入项目库 3D 模型页。
|
||||||
|
2. 前端根据实体化程度选择 STL preview limit 请求后端抽样数据。
|
||||||
|
3. Three.js 将各 STL 抽样三角面生成实体 mesh,按当前实体化、颜色、透明度、白色实体模式、整体位姿状态渲染。
|
||||||
|
4. 用户拖动位姿控制滑块,前端立即更新 group 的 rotation / position / scale。
|
||||||
|
5. 用户在 DICOM 页长按切片按钮,前端连续改变 `sliceIndex`,后端返回切片预览,前端按最新请求序号绘制最新帧。
|
||||||
|
6. 用户进入逆向工作区,只看到全局 header 的“逆向工作区”和加大后的当前项目标签行。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- 实体化程度默认使用“标准”,若性能不佳可切回“预览”。
|
||||||
|
- WebGL 不可用时继续使用二维投影兜底,实体化和位姿控制主要作用于 WebGL 路径。
|
||||||
|
- 若提高抽样上限导致性能问题,可只回滚实体化程度 limit 参数,不影响项目列表、DICOM 和导出功能。
|
||||||
|
- 逆向工作区文案调整为纯 UI 变更,可直接回滚组件 JSX。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- `ProjectLibrary.tsx`:新增 3D 渲染控制状态、实体化程度、位姿控制 UI、DICOM 请求序号和切片过渡反馈。
|
||||||
|
- `server.ts`:可能提高 STL preview 的安全上限。
|
||||||
|
- `ReverseWorkspace.tsx`:删除重复标题/副标题,调整标签字号。
|
||||||
|
- `经验记录.md`:执行后追加本次经验。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已回复“确认方案”,按已确认方案执行项目业务代码修改。
|
||||||
73
工程分析/测试方案-2026-05-04-05-41-22.md
Normal file
73
工程分析/测试方案-2026-05-04-05-41-22.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# 测试方案 - 2026-05-04-05-41-22
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
- 执行 `npm run lint`,确认 TypeScript 类型检查通过。
|
||||||
|
- 执行 `npm run build`,确认生产构建通过。
|
||||||
|
|
||||||
|
## 集成测试
|
||||||
|
|
||||||
|
- 调用 STL preview API:
|
||||||
|
- `limit=6000`
|
||||||
|
- `limit=16000`
|
||||||
|
- `limit=36000`
|
||||||
|
- 确认返回顶点数量随实体化程度增加,且响应不报错。
|
||||||
|
- 调用 DICOM preview API,确认连续请求不同 slice 时均可返回合理 `slice/total/width/height`。
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
### 3D 模型页
|
||||||
|
|
||||||
|
- 默认进入后模型可见。
|
||||||
|
- 实体化程度从“预览”切换到“标准/精细”后,模型表面更连续,而不是纯点云感。
|
||||||
|
- 开启“整体白色实体”后,显示效果接近参考图的灰白实体模型。
|
||||||
|
- 调整旋转 X/Y/Z、平移 X/Y/Z、缩放后,模型位姿即时变化。
|
||||||
|
- 点击重置后,位姿恢复默认。
|
||||||
|
- 隐藏/显示单个 STL、调整颜色、透明度仍正常。
|
||||||
|
- WebGL 不可用时二维兜底仍可见,不出现空白画布。
|
||||||
|
|
||||||
|
### DICOM 切片页
|
||||||
|
|
||||||
|
- 长按上箭头时,切片编号连续增加,图像连续变化。
|
||||||
|
- 长按下箭头时,切片编号连续减少,图像连续变化。
|
||||||
|
- 快速连续切换时,不出现旧请求覆盖新切片的错帧。
|
||||||
|
- 到达首帧/末帧时不越界。
|
||||||
|
|
||||||
|
### 逆向工作区
|
||||||
|
|
||||||
|
- 顶部全局 header 仍显示“逆向工作区”。
|
||||||
|
- 页面内容区不再重复显示第二个“逆向工作区”。
|
||||||
|
- 页面内容区不再出现上方重复“当前项目:xxx”副标题。
|
||||||
|
- 项目标签行字号变大,且显示当前项目、DICOM 数量、STL 数量。
|
||||||
|
|
||||||
|
## 医学影像数据相关边界验证
|
||||||
|
|
||||||
|
- DICOM 连续切片时保留当前 plane、mode、rotation 状态。
|
||||||
|
- STL 实体化程度不改变各构件的原始相对空间关系,只改变抽样密度和显示材质。
|
||||||
|
- 位姿控制作用于整体模型 group,不改变单个 STL 的相对拼装位置。
|
||||||
|
|
||||||
|
## 回归风险
|
||||||
|
|
||||||
|
- 高实体化程度可能提升渲染压力,需要在 UI 中保留“预览”档位。
|
||||||
|
- DICOM 连续请求可能造成响应乱序,需要请求序号保护。
|
||||||
|
- 逆向工作区删除标题后仍需保证用户知道当前处于哪个页面,依赖全局 header。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已回复“确认方案”,按本测试方案执行验证。
|
||||||
|
|
||||||
|
## 执行结果
|
||||||
|
|
||||||
|
- `npm run lint`:通过。
|
||||||
|
- `npm run build`:通过;Vite 仍提示 bundle 超过 500 kB,为现有 Three.js/Recharts 依赖带来的非阻断警告。
|
||||||
|
- STL preview API 验证:
|
||||||
|
- `limit=6000`:返回 `5795` 个抽样三角面、`52155` 个顶点数值。
|
||||||
|
- `limit=16000`:返回 `8692` 个抽样三角面、`78228` 个顶点数值。
|
||||||
|
- `limit=36000`:返回 `17384` 个抽样三角面、`156456` 个顶点数值。
|
||||||
|
- DICOM 连续切片 API 验证:`slice=120~124` 均返回 `512x512`、`total=300`,文件名按 `121.dcm` 到 `125.dcm` 连续变化。
|
||||||
|
- 无头 Chrome 前端验证:
|
||||||
|
- 3D 模型页存在“模型显示、预览、标准、精细、白色实体、整体位姿、旋转 X、平移 Z、缩放”等控件。
|
||||||
|
- WebGL 不可用场景仍生成二维预览 canvas,尺寸 `1172x567`。
|
||||||
|
- 逆向工作区正文不再重复页面标题:`逆向工作区` 只出现 1 次。
|
||||||
|
- `当前项目:头部 CT 模型逆向体素化演示` 只出现 1 次,并保留 `DICOM 300`、`STL 9`。
|
||||||
|
- 已重新部署到 `http://192.168.3.11:4000/`,tmux 会话:`revoxelseg-dicom`。
|
||||||
54
工程分析/经验记录.md
54
工程分析/经验记录.md
@@ -451,3 +451,57 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
工作区首屏应优先显示“当前正在处理哪个项目”和“下一步操作”,底层路径只在诊断、详情或设置区域出现。
|
工作区首屏应优先显示“当前正在处理哪个项目”和“下一步操作”,底层路径只在诊断、详情或设置区域出现。
|
||||||
|
|
||||||
|
## 2026-05-04-05-41-22 STL 实体化程度与位姿控制
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
3D 模型虽然可以显示,但抽样三角面过少时视觉上偏稀疏,用户希望更接近白色实体 STL 拼装效果,并且能够手动调整整体模型位姿。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
项目库为了避免大体积 STL 造成浏览器卡顿,使用固定低抽样量预览;同时模型 group 只有自动旋转,没有暴露旋转、平移、缩放等用户控制。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
新增“预览/标准/精细”实体化程度,后端 STL preview 抽样上限提升到 `36000`;前端新增白色实体模式、自动旋转开关、旋转 X/Y/Z、平移 X/Y/Z、缩放和重置位姿控制,所有位姿变换作用在整体 group 上。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
大模型预览应提供性能档位,而不是在“速度”和“实体感”之间固定取舍;位姿控制必须作用于整体模型容器,避免破坏各 STL 构件的相对拼装关系。
|
||||||
|
|
||||||
|
## 2026-05-04-05-41-22 DICOM 长按切片反馈
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
长按切片上下箭头时,编号会变化,但用户希望图像也有连续变化的运动感。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
前端连续修改 `sliceIndex` 后依赖异步 API 返回更新图像,缺少请求乱序保护和切片切换中的视觉反馈。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
将长按步进间隔缩短到 `95ms`,为 DICOM preview 请求加入递增序号,只允许最新请求更新画面;切片切换时给 canvas 容器增加轻微缩放、亮度和状态提示。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
连续浏览医学影像时,必须同时处理“状态连续变化”“图像异步返回”和“旧请求覆盖新请求”三个问题;前端应保留上一帧并提供明确切换反馈。
|
||||||
|
|
||||||
|
## 2026-05-04-05-41-22 逆向工作区标题去重
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
逆向工作区同时出现全局标题和页面内标题,也重复显示“当前项目”。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
页面局部标题和全局 header 没有统一职责,项目上下文既出现在副标题,也出现在标签行。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
保留全局 header 的“逆向工作区”,删除页面内重复标题和副标题;保留并放大当前项目、DICOM 数量、STL 数量标签。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
页面级标题应由全局导航或内容区二选一承担;当前对象信息只保留在最醒目的单一位置,减少重复文本造成的噪声。
|
||||||
|
|||||||
51
工程分析/需求分析-2026-05-04-05-41-22.md
Normal file
51
工程分析/需求分析-2026-05-04-05-41-22.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 需求分析 - 2026-05-04-05-41-22
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
用户基于当前项目库和逆向工作区提出 3 项调整:
|
||||||
|
|
||||||
|
1. 3D 模型已有显示效果,但希望更接近参考图中的实体 STL 拼装效果,而不是现在偏点云/稀疏预览;同时需要可以调整整体 3D 模型位姿。
|
||||||
|
2. DICOM 切片长按上下箭头时,图片也要连续跟随变化,形成明显的图像运动感。
|
||||||
|
3. 逆向工作区中存在重复信息:
|
||||||
|
- 有两个“当前项目:头部 CT 模型逆向体素化演示”,删除上方那个。
|
||||||
|
- 有两个“逆向工作区”,删除下方那个。
|
||||||
|
- “当前项目:头部 CT 模型逆向体素化演示 DICOM 300 STL 9”字号调大。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
项目库的 3D 模型视图应能更像真实 STL 实体表面预览,用户可通过控件调整模型整体姿态,便于对照参考图观察头颅、气管、肿瘤等结构;DICOM 切片浏览需要有连续播放/滚动的即时反馈;逆向工作区标题区应减少重复文本,强化当前项目上下文。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
- 输入:
|
||||||
|
- `Head_CT_ReConstruct/` 中的 STL 文件。
|
||||||
|
- `Head_CT_DICOM/` 中的 DICOM 序列。
|
||||||
|
- 用户在 UI 上的切片长按、实体化程度、位姿调节输入。
|
||||||
|
- 输出:
|
||||||
|
- 3D 模型实体化程度控制与位姿控制 UI。
|
||||||
|
- 长按切片时连续请求并绘制 DICOM 图像。
|
||||||
|
- 精简后的逆向工作区头部信息。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- STL 预览渲染方式、实体化/点云程度控制、整体位姿控制。
|
||||||
|
- DICOM 长按切片刷新节奏与视觉反馈。
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- 删除页面内重复标题和上方重复“当前项目”描述。
|
||||||
|
- 调整项目状态标签字号。
|
||||||
|
- `WebSite/src/App.tsx`
|
||||||
|
- 保留全局 header 的“逆向工作区”,页面内不再重复。
|
||||||
|
- `WebSite/server.ts`
|
||||||
|
- 如需提高实体化程度,可能调整 STL 预览抽样上限或返回更多三角面数据。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- STL 原始文件总量较大,提高实体化程度会增加网络传输和浏览器渲染压力。
|
||||||
|
- 如果直接加载完整 STL,可能再次导致页面卡顿或空白;应采用可调采样密度或分级预览。
|
||||||
|
- 长按切片连续刷新会增加 DICOM preview API 请求频率,需要节流并避免响应乱序。
|
||||||
|
- 位姿控制需要避免和自动旋转逻辑互相冲突。
|
||||||
|
|
||||||
|
## 待确认问题
|
||||||
|
|
||||||
|
本次用户没有明确跳过二次确认,因此按照工作流,需等待实现方案和测试方案审核确认后再修改业务代码。
|
||||||
Reference in New Issue
Block a user