2026-05-08-01-19-42 优化融合视角和模型位姿控制
This commit is contained in:
@@ -1111,7 +1111,7 @@ export default function ProjectLibrary({
|
||||
|
||||
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden p-8">
|
||||
{viewMode === 'dicom' && (
|
||||
<div className="h-full flex gap-8">
|
||||
<div className="h-full min-h-0 flex gap-8">
|
||||
{/* Left: DICOM Viewer */}
|
||||
<div className="flex-1 bg-slate-950 rounded-2xl relative border border-slate-800 flex items-center justify-center p-12">
|
||||
<div className="absolute top-4 right-4 z-10 flex rounded-lg bg-white/5 p-1 backdrop-blur-sm border border-white/10">
|
||||
@@ -1270,8 +1270,8 @@ export default function ProjectLibrary({
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: Sub-module List */}
|
||||
<div className="w-80 h-full flex flex-col overflow-hidden">
|
||||
<div className="shrink-0 space-y-4 pb-4">
|
||||
<div className="w-80 h-full min-h-0 overflow-y-auto pr-1 scrollbar-hide">
|
||||
<div className="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>
|
||||
@@ -1360,7 +1360,7 @@ export default function ProjectLibrary({
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
|
||||
<div className="space-y-2 pb-4">
|
||||
{stlFiles.map((fileName, i) => {
|
||||
const name = fileName.replace(/\.stl$/i, '');
|
||||
const style = moduleStyles[fileName] ?? { visible: true, color: defaultModuleColors[i % defaultModuleColors.length], opacity: 0.72, partId: i + 1 };
|
||||
|
||||
@@ -37,6 +37,8 @@ interface ModelPreviewPayload {
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -44,6 +46,20 @@ const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }>
|
||||
{ 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,
|
||||
@@ -97,6 +113,10 @@ function FusionThreeView({
|
||||
moduleStyles,
|
||||
detailLimit,
|
||||
solidMode,
|
||||
dicomOpacity,
|
||||
showBounds,
|
||||
cutEnabled,
|
||||
cutSlice,
|
||||
}: {
|
||||
project: Project;
|
||||
volume: DicomFusionVolume | null;
|
||||
@@ -104,6 +124,10 @@ function FusionThreeView({
|
||||
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);
|
||||
@@ -137,6 +161,7 @@ function FusionThreeView({
|
||||
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));
|
||||
@@ -165,15 +190,34 @@ function FusionThreeView({
|
||||
|
||||
const box = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(dicomWidth, dicomHeight, dicomDepth),
|
||||
new THREE.MeshBasicMaterial({ color: '#020617', transparent: true, opacity: 0.32, depthWrite: false }),
|
||||
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);
|
||||
@@ -183,7 +227,7 @@ function FusionThreeView({
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: isLast ? 0.82 : 0.12,
|
||||
opacity: isLast ? dicomOpacity.sliceOpacity : dicomOpacity.volumeOpacity,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
@@ -227,6 +271,8 @@ function FusionThreeView({
|
||||
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);
|
||||
@@ -262,8 +308,15 @@ function FusionThreeView({
|
||||
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, dicomDepth * 0.08);
|
||||
modelPoseGroup.position.set(0, 0, 0);
|
||||
modelPivot.position.set(0, 0, dicomDepth * 0.08);
|
||||
setLoadProgress(100);
|
||||
setStatus(stlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前项目没有 STL');
|
||||
});
|
||||
@@ -339,6 +392,12 @@ function FusionThreeView({
|
||||
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(
|
||||
@@ -349,7 +408,7 @@ function FusionThreeView({
|
||||
modelPoseGroup.position.set(
|
||||
pose.translateX,
|
||||
pose.translateY,
|
||||
dicomDepth * 0.08 + pose.translateZ,
|
||||
pose.translateZ,
|
||||
);
|
||||
modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale);
|
||||
renderer.render(scene, camera);
|
||||
@@ -382,7 +441,20 @@ function FusionThreeView({
|
||||
renderer.dispose();
|
||||
container.innerHTML = '';
|
||||
};
|
||||
}, [project.id, project.stlFiles?.join('|'), volume, JSON.stringify(moduleStyles), detailLimit, solidMode]);
|
||||
}, [
|
||||
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">
|
||||
@@ -417,6 +489,10 @@ 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 },
|
||||
@@ -431,6 +507,9 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
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 },
|
||||
@@ -477,11 +556,30 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
});
|
||||
};
|
||||
|
||||
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) => {
|
||||
@@ -497,11 +595,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
useEffect(() => {
|
||||
if (!project?.dicomCount) return;
|
||||
const maxSlice = Math.max(project.dicomCount - 1, 0);
|
||||
const safeStart = 0;
|
||||
const safeEnd = clamp(sliceEnd, 0, maxSlice);
|
||||
const timer = window.setTimeout(() => {
|
||||
setFusionError('');
|
||||
api.getDicomFusionVolume(project.id, safeStart, safeEnd, 'soft')
|
||||
loadFusionVolume(safeEnd)
|
||||
.then(setFusionVolume)
|
||||
.catch((error) => {
|
||||
setFusionVolume(null);
|
||||
@@ -511,6 +608,15 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
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);
|
||||
@@ -530,6 +636,59 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
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));
|
||||
@@ -557,6 +716,14 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
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;
|
||||
@@ -568,6 +735,30 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
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">
|
||||
@@ -624,6 +815,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
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">
|
||||
@@ -660,6 +855,32 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
<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>
|
||||
|
||||
@@ -690,6 +911,59 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</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>
|
||||
@@ -708,38 +982,97 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => {
|
||||
setModelPose(defaultModelPose);
|
||||
setSelectedPoseId('default');
|
||||
}}
|
||||
className="h-8 w-full rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
|
||||
>
|
||||
重置默认位姿
|
||||
</button>
|
||||
{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', min: -180, max: 180, step: 1, value: modelPose.rotateX },
|
||||
{ key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY },
|
||||
{ key: 'rotateZ' as const, label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ },
|
||||
{ key: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX },
|
||||
{ key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY },
|
||||
{ key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ },
|
||||
{ key: 'scale' as const, label: '缩放', min: 0.5, max: 2, step: 0.05, value: modelPose.scale },
|
||||
{ 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) => (
|
||||
<label key={item.key} className="grid grid-cols-[44px_1fr_34px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
||||
{item.label}
|
||||
<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={item.min}
|
||||
max={item.max}
|
||||
step={item.step}
|
||||
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"
|
||||
/>
|
||||
<span className="text-right font-mono">{Number(item.value).toFixed(item.step < 1 ? 2 : 0)}</span>
|
||||
</label>
|
||||
<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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BarChart3,
|
||||
FolderRoot,
|
||||
Box,
|
||||
Workflow,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
@@ -32,7 +33,7 @@ export default function Sidebar({
|
||||
{ id: ViewType.OVERVIEW, icon: BarChart3, label: '总体概况' },
|
||||
{ id: ViewType.PROJECTS, icon: FolderRoot, label: '项目库' },
|
||||
{ id: ViewType.MODELS, icon: Box, label: '模型库' },
|
||||
{ id: ViewType.WORKSPACE, icon: Box, label: '逆向工作区' },
|
||||
{ id: ViewType.WORKSPACE, icon: Workflow, label: '逆向工作区' },
|
||||
{ id: ViewType.SYSTEM, icon: Settings, label: '系统管理工作区' },
|
||||
];
|
||||
|
||||
|
||||
108
工程分析/实现方案-2026-05-08-01-19-42.md
Normal file
108
工程分析/实现方案-2026-05-08-01-19-42.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 实现方案 - 2026-05-08-01-19-42
|
||||
|
||||
## 修改目标
|
||||
|
||||
1. 修复模型库大缩放时构件层级不可见或不可滚动的问题。
|
||||
2. 更换逆向工作区侧栏图标,避免与模型库重复。
|
||||
3. 优化逆向工作区模型位姿控制:拆分重置按钮、增加 ±90° 旋转、位姿重命名、单击最低刻度、长按连续移动。
|
||||
4. 为融合视角增加 DICOM 体数据缓存与五个预存点位。
|
||||
5. 在融合视角显示 DICOM 边界框、模型边界框,并以 DICOM 中心作为模型旋转中心。
|
||||
6. 增加 DICOM 透明度三档控制。
|
||||
7. 增加基于 DICOM 帧的模型切分预览,显示切割面。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/Sidebar.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
### 模型库滚动
|
||||
|
||||
- 调整项目库 3D 模型页右侧栏为 `min-h-0` + 内部滚动结构。
|
||||
- 构件层级列表占用剩余高度,标题和控制区固定,防止浏览器放大后构件列表被挤出。
|
||||
|
||||
### 侧栏图标
|
||||
|
||||
- 侧栏中模型库继续使用 `Box`,逆向工作区改用 `Workflow` 或 `ScanLine` 类图标,保证含义和视觉不同。
|
||||
|
||||
### 位姿控制
|
||||
|
||||
- 在逆向工作区可视化工具栏中替换 `重置默认位姿` 为:
|
||||
- `重置旋转位姿`
|
||||
- `重置平移缩放位姿`
|
||||
- 旋转 X/Y/Z 增加 `-90°` 和 `+90°` 按钮。
|
||||
- 平移和缩放增加正负最小步进按钮。
|
||||
- 单击按钮只移动一个最低刻度;鼠标或触摸长按时启动定时器连续移动。
|
||||
- 保存位姿列表中允许重命名非默认位姿,默认位姿保持不可改名。
|
||||
|
||||
### DICOM 缓存与预存点
|
||||
|
||||
- 前端维护 `fusionVolumeCacheRef`,以 `projectId/mode/end` 作为 key 缓存融合体数据。
|
||||
- DICOM 切片范围显示五个点位按钮,分别映射 20%、40%、60%、80%、100%。
|
||||
- 点击预存点位时优先从缓存读取;缓存未命中则请求接口并写入缓存。
|
||||
- 增加“预存五点”按钮,后台并发预取五个点位。
|
||||
|
||||
### 边界框与旋转中心
|
||||
|
||||
- FusionThreeView 增加 DICOM BoxHelper/LineSegments 边界和模型边界。
|
||||
- 模型 STL 顶点以自身中心归一化后,整体 group 的 pivot 固定在 DICOM 体中心。
|
||||
- 旋转、缩放围绕 DICOM 中心进行,平移作为模型相对 DICOM 中心的偏移。
|
||||
|
||||
### DICOM 透明度
|
||||
|
||||
- 新增 `dicomOpacityLevel`,提供低/中/高三档。
|
||||
- 传入 FusionThreeView,控制 DICOM 体素切片和切片纹理材质透明度。
|
||||
|
||||
### 模型切分
|
||||
|
||||
- 新增切割开关、切割帧滑条和切割面显示。
|
||||
- Three.js 中使用 DICOM 切片帧在 Z 方向的相对位置作为切割平面。
|
||||
- 通过 local clipping plane 对模型材质裁剪,并显示半透明切割面。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
1. 进入逆向工作区后读取项目与 `moduleStyles`。
|
||||
2. Fusion 视角按当前切片范围加载体数据,先查前端缓存,未命中再请求后端。
|
||||
3. 用户点击五个点位或预存按钮,前端将对应 `end` 的 DICOM volume 写入缓存。
|
||||
4. 用户调整模型位姿,模型围绕 DICOM 中心旋转缩放,并实时更新边界框。
|
||||
5. 用户启用模型切分并选择 DICOM 帧,Three.js 使用对应切割平面裁剪模型并显示切割面。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 缓存仅存在浏览器内存中,不改变后端数据结构。
|
||||
- 新增控制项都保留默认值,旧项目数据可直接使用。
|
||||
- 若切割平面对部分浏览器性能影响较大,可关闭切割功能回到原始渲染。
|
||||
- 回滚本次 commit 即可恢复。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `Sidebar.tsx`:调整逆向工作区图标。
|
||||
- `ProjectLibrary.tsx`:修复右侧构件层级滚动布局。
|
||||
- `ReverseWorkspace.tsx`:增加缓存、预存点、位姿控制、边界框、透明度和切割功能。
|
||||
- `api.ts/types.ts/server.ts`:如需支持缓存辅助或类型扩展则同步调整。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 已将左侧逆向工作区图标从 `Box` 改为 `Workflow`,与模型库图标区分。
|
||||
- 已将模型库 3D 模型右侧栏改为整栏滚动结构,解决浏览器放大后构件层级下方内容不可见的问题。
|
||||
- 已在逆向工作区模型位姿中增加:
|
||||
- `重置旋转位姿`
|
||||
- `重置平移缩放位姿`
|
||||
- X/Y/Z 的 ±90° 快捷旋转
|
||||
- 旋转/平移/缩放的最低刻度单击步进与长按连续步进
|
||||
- 非默认位姿重命名输入
|
||||
- 已在融合视角中增加 DICOM 透明度低/中/高三档。
|
||||
- 已在融合视角中增加 DICOM 边界框和模型边界框显示开关。
|
||||
- 已将模型旋转中心改为以 DICOM 体中心为 pivot,模型自身偏移放入子节点,避免旋转时围绕模型偏移点运动。
|
||||
- 已增加 DICOM 切片范围五个点位和 `预存五点` 功能,前端使用内存 Map 缓存融合体数据。
|
||||
- 已增加模型切分开关、切割帧滑条、橙色切割面显示,并通过 Three.js clipping plane 裁剪模型。
|
||||
58
工程分析/测试方案-2026-05-08-01-19-42.md
Normal file
58
工程分析/测试方案-2026-05-08-01-19-42.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 测试方案 - 2026-05-08-01-19-42
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 执行 `npm run lint`,确认 TypeScript 类型检查通过。
|
||||
- 执行 `npm run build`,确认生产构建通过。
|
||||
- 执行 `git diff --check`,确认无空白错误。
|
||||
|
||||
## 集成验证
|
||||
|
||||
- `GET /api/projects/head-ct-demo` 正常返回项目与构件样式。
|
||||
- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=0&end=<n>&mode=soft` 正常返回体数据。
|
||||
- 本地服务重新部署后 `curl -I http://127.0.0.1:4000/` 返回 200。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
1. 模型库在放大或小高度场景下,右侧构件层级仍可滚动查看。
|
||||
2. 左侧逆向工作区图标与模型库图标不同。
|
||||
3. 逆向工作区模型位姿支持:
|
||||
- 重置旋转位姿。
|
||||
- 重置平移缩放位姿。
|
||||
- X/Y/Z ±90° 快捷旋转。
|
||||
- 非默认位姿重命名。
|
||||
- 单击最低刻度移动,长按连续移动。
|
||||
4. DICOM 切片范围提供五个预存点位,并能触发缓存加载。
|
||||
5. 融合视角中可看到 DICOM 边界框、模型边界框。
|
||||
6. 模型旋转中心围绕 DICOM 体中心。
|
||||
7. DICOM 透明度三档切换可见。
|
||||
8. 模型切分开关、帧选择和切割面显示可用。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 切片范围点位不超过 DICOM 总帧数。
|
||||
- 切割帧范围限制在 `1~dicomCount`。
|
||||
- DICOM 体数据缓存只缓存当前项目和当前模式下的数据,避免混用其他项目数据。
|
||||
|
||||
## 回归风险
|
||||
|
||||
- Three.js clipping plane 和边界框可能影响渲染性能。
|
||||
- 预存五点会产生多次融合体请求,需要确认不会阻塞主界面交互。
|
||||
- 模型旋转中心改为 DICOM 中心后,旧的模型位姿视觉效果会发生变化。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:通过,仅保留 Vite 大 chunk 体积提醒。
|
||||
- `git diff --check`:通过。
|
||||
- 重新部署:已通过 `tmux` 重启 `revoxelseg-dicom` 服务,运行在 `http://0.0.0.0:4000/`。
|
||||
- `curl -I http://127.0.0.1:4000/`:返回 `HTTP/1.1 200 OK`。
|
||||
- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=0&end=59&mode=soft`:正常返回 DICOM 融合体数据。
|
||||
- 代码静态检索确认:
|
||||
- `重置默认位姿` 已移除。
|
||||
- `Workflow` 已用于逆向工作区侧栏图标。
|
||||
- `预存五点`、`模型切分`、`dicomOpacity`、`showBounds` 相关控制已写入逆向工作区。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -685,3 +685,21 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
凡是需要跨页面、跨浏览器一致的配置项,优先放入后端项目状态;前端可以保留本地 state 提升响应速度,但必须通过 API 持久化并从项目数据恢复。
|
||||
|
||||
## 2026-05-08-01-19-42 融合视角缓存、边界与切割控制
|
||||
|
||||
A. 具体问题
|
||||
|
||||
融合视角在切片范围变化时需要反复请求 DICOM 体数据;模型旋转中心带有模型偏移,极端旋转后容易看起来脱离 DICOM;同时缺少 DICOM/模型边界、透明度档位和沿 DICOM 帧切割模型的操作入口。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
前一版融合视图以单次加载为主,没有前端缓存点位;模型居中后又把模型组整体设置了 Z 偏移,使旋转 pivot 不完全等同于 DICOM 体中心;三维辅助元素只显示了 DICOM 边线,没有完整的模型边界和切割平面。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
在前端使用 `Map` 按项目、模式和切片终点缓存 `DicomFusionVolume`;DICOM 切片范围增加五个预存点位和一键预存;Three.js 中将模型旋转组保持在 DICOM 原点,把模型自身偏移放入子节点;增加 DICOM 边界、模型边界、DICOM 透明度三档、切割帧滑条和 clipping plane 切割面。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
涉及 DICOM 与 STL 配准的旋转、缩放、切割操作,应优先明确坐标系和 pivot 来源,默认以 DICOM 体中心为基准;高频切片请求应先设计缓存键和预取策略,避免 UI 操作直接触发重复网络与纹理构建。
|
||||
|
||||
56
工程分析/需求分析-2026-05-08-01-19-42.md
Normal file
56
工程分析/需求分析-2026-05-08-01-19-42.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 需求分析 - 2026-05-08-01-19-42
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
本次需要继续完善模型库与逆向工作区的三维融合可视化能力:
|
||||
|
||||
1. 模型库界面放大后右侧构件层级内容被挤出或不可见,需要解决滚动与布局问题。
|
||||
2. 左侧逆向工作区图标需要更换,避免与模型库图标重复。
|
||||
3. 逆向工作区中 `重置默认位姿` 改为 `重置旋转位姿`、`重置平移缩放位姿`,旋转 X/Y/Z 增加 ±90° 快捷旋转。
|
||||
4. 模型位姿中除默认位姿外,支持位姿改名。
|
||||
5. 影像与模型融合视角增加缓存/预存能力,DICOM 切片范围可选择五个点位预存信息,加速可视化。
|
||||
6. 融合视角同时显示 DICOM 体数据矩形边界和模型矩形边界。
|
||||
7. 模型旋转中心改为患者 DICOM 影像中心,避免模型旋转后脱离 DICOM。
|
||||
8. 可视化工具栏中旋转、平移缩放单击只移动最低刻度,长按才连续移动。
|
||||
9. DICOM 三维影像透明度增加两档可选。
|
||||
10. 增加模型切分功能:可沿一个 DICOM 帧切割模型,并显示切割面。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 提升大缩放或小视口下的模型库可用性。
|
||||
- 让逆向工作区和模型库在导航语义上更清晰。
|
||||
- 增强模型位姿操作的可控性、可保存性和可解释性。
|
||||
- 加快融合视角切片范围变化时的数据加载速度。
|
||||
- 增强 DICOM 与 STL 的空间对比能力,为后续配准和模型切分提供基础。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:
|
||||
- 用户在模型库查看构件层级。
|
||||
- 用户调整逆向工作区模型位姿、保存/重命名位姿、切片范围、DICOM 透明度、切割帧。
|
||||
- 输出:
|
||||
- 模型库右侧构件层级在放大后仍可滚动查看。
|
||||
- 逆向工作区左侧图标与模型库不同。
|
||||
- 逆向工作区可进行更细粒度和长按连续的位姿调节。
|
||||
- 融合视角显示 DICOM 边界、模型边界、DICOM 透明度档位、预存点位、切割面。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- `WebSite/src/components/Sidebar.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 风险点
|
||||
|
||||
- 三维融合视图已包含 DICOM 体数据、STL 模型和位姿控制,继续加入边界框、切割面和缓存后需要避免 Three.js 对象重复泄漏。
|
||||
- DICOM 预存点位如果一次预取过多,可能增加服务端与浏览器内存压力。
|
||||
- 旋转中心改为 DICOM 中心后,模型本身的居中逻辑要与 DICOM 坐标系保持一致,不能再次叠加偏移。
|
||||
- 频繁保存位姿名称或构件样式时,需要保持前端状态和后端项目状态一致。
|
||||
|
||||
## 待确认问题
|
||||
|
||||
用户已明确本次需求分析、实现方案、测试方案、执行修改均不需要二次人工确认,因此按默认执行确认规则直接实施。
|
||||
Reference in New Issue
Block a user