2026-05-07-18-42-53 优化可视化工具栏和构件ID联动
This commit is contained in:
@@ -7,13 +7,12 @@ import {
|
||||
Download,
|
||||
Rotate3d,
|
||||
AlertCircle,
|
||||
FileJson,
|
||||
Play,
|
||||
Eye,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
import { DicomFusionVolume, MaskMapping, Project } from '../types';
|
||||
import { DicomFusionVolume, MaskMapping, ModuleStyle, Project } from '../types';
|
||||
import { api, downloadMask } from '../lib/api';
|
||||
|
||||
interface ModelPose {
|
||||
@@ -37,13 +36,6 @@ interface ModelPreviewPayload {
|
||||
};
|
||||
}
|
||||
|
||||
interface ModuleStyle {
|
||||
visible: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
partId: number;
|
||||
}
|
||||
|
||||
type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||
|
||||
const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
|
||||
@@ -422,7 +414,6 @@ function FusionThreeView({
|
||||
}
|
||||
|
||||
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
const [sliceStart, setSliceStart] = useState(0);
|
||||
const [sliceEnd, setSliceEnd] = useState(49);
|
||||
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
||||
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
|
||||
@@ -465,21 +456,36 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
|
||||
visible: fallback?.visible ?? true,
|
||||
color: fallback?.color ?? moduleColors[index % moduleColors.length],
|
||||
opacity: fallback?.opacity ?? 0.72,
|
||||
partId: clamp(Math.round(fallback?.partId ?? index + 1), 1, 255),
|
||||
});
|
||||
|
||||
const commitModuleStyles = (next: Record<string, ModuleStyle>) => {
|
||||
setModuleStyles(next);
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
api.updateProjectModuleStyles(project.id, next)
|
||||
.then((updated) => {
|
||||
setProject(updated);
|
||||
})
|
||||
.catch(() => {
|
||||
setFusionError('构件样式保存失败,请稍后重试');
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.getProject(projectId).then((item) => {
|
||||
setProject(item);
|
||||
const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0));
|
||||
setSliceStart(0);
|
||||
setSliceEnd(end);
|
||||
setModelPose(defaultModelPose);
|
||||
const nextStyles: Record<string, ModuleStyle> = {};
|
||||
(item.stlFiles ?? []).forEach((fileName, index) => {
|
||||
nextStyles[fileName] = {
|
||||
visible: true,
|
||||
color: moduleColors[index % moduleColors.length],
|
||||
opacity: 0.72,
|
||||
partId: index + 1,
|
||||
};
|
||||
nextStyles[fileName] = makeDefaultModuleStyle(index, item.moduleStyles?.[fileName]);
|
||||
});
|
||||
setModuleStyles(nextStyles);
|
||||
}).catch(() => {
|
||||
@@ -491,8 +497,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
useEffect(() => {
|
||||
if (!project?.dicomCount) return;
|
||||
const maxSlice = Math.max(project.dicomCount - 1, 0);
|
||||
const safeStart = clamp(Math.min(sliceStart, sliceEnd), 0, maxSlice);
|
||||
const safeEnd = clamp(Math.max(sliceStart, sliceEnd), safeStart, maxSlice);
|
||||
const safeStart = 0;
|
||||
const safeEnd = clamp(sliceEnd, 0, maxSlice);
|
||||
const timer = window.setTimeout(() => {
|
||||
setFusionError('');
|
||||
api.getDicomFusionVolume(project.id, safeStart, safeEnd, 'soft')
|
||||
@@ -503,7 +509,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
});
|
||||
}, 180);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [project?.id, project?.dicomCount, sliceStart, sliceEnd]);
|
||||
}, [project?.id, project?.dicomCount, sliceEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRegistering && progress < 100) {
|
||||
@@ -525,17 +531,16 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
};
|
||||
|
||||
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
||||
setModuleStyles((current) => ({
|
||||
...current,
|
||||
[fileName]: {
|
||||
visible: true,
|
||||
color: '#3b82f6',
|
||||
opacity: 0.72,
|
||||
partId: 1,
|
||||
...(current[fileName] ?? {}),
|
||||
const stlFiles = project?.stlFiles ?? [];
|
||||
const index = Math.max(0, stlFiles.indexOf(fileName));
|
||||
const next = {
|
||||
...moduleStyles,
|
||||
[fileName]: makeDefaultModuleStyle(index, {
|
||||
...(moduleStyles[fileName] ?? project?.moduleStyles?.[fileName]),
|
||||
...partial,
|
||||
},
|
||||
}));
|
||||
}),
|
||||
};
|
||||
commitModuleStyles(next);
|
||||
};
|
||||
|
||||
const updateModulePartId = (fileName: string, value: number) => {
|
||||
@@ -560,8 +565,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
};
|
||||
|
||||
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
|
||||
const displayStart = Math.min(sliceStart, sliceEnd);
|
||||
const displayEnd = Math.max(sliceStart, sliceEnd);
|
||||
const displayStart = 0;
|
||||
const displayEnd = clamp(sliceEnd, 0, maxSlice);
|
||||
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
||||
|
||||
return (
|
||||
@@ -633,81 +638,28 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-xs font-bold text-slate-700">DICOM 切片范围</p>
|
||||
<span className="text-[10px] font-mono text-blue-600">
|
||||
{displayStart + 1} - {displayEnd + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="grid grid-cols-[52px_1fr_42px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
||||
起点
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliceStart}
|
||||
onChange={(event) => setSliceStart(Number(event.target.value))}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<span className="text-right font-mono">{sliceStart + 1}</span>
|
||||
</label>
|
||||
<label className="grid grid-cols-[52px_1fr_42px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
||||
终点
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliceEnd}
|
||||
onChange={(event) => setSliceEnd(Number(event.target.value))}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<span className="text-right font-mono">{sliceEnd + 1}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-3 text-[10px] leading-5 text-slate-400">
|
||||
DICOM 以黑色体数据长方体显示,表面贴附当前范围的最后一张 CT 切片。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-xs font-bold text-slate-700">模型位姿</p>
|
||||
<button
|
||||
onClick={() => setModelPose(defaultModelPose)}
|
||||
className="text-[10px] font-bold text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
重置模型位姿
|
||||
</button>
|
||||
</div>
|
||||
<div className="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 },
|
||||
].map((item) => (
|
||||
<label key={item.key} className="grid grid-cols-[52px_1fr_42px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
||||
{item.label}
|
||||
<input
|
||||
type="range"
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
step={item.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>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-xs font-bold text-slate-700">DICOM 切片范围</p>
|
||||
<span className="text-[10px] font-mono text-blue-600">
|
||||
{displayStart + 1} - {displayEnd + 1} / {project?.dicomCount ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<label className="grid grid-cols-[76px_1fr_64px] items-center gap-3 text-[10px] font-bold text-slate-500">
|
||||
显示范围
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={displayEnd}
|
||||
onChange={(event) => setSliceEnd(Number(event.target.value))}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<span className="text-right font-mono">{displayEnd + 1} 张</span>
|
||||
</label>
|
||||
<p className="mt-3 text-[10px] leading-5 text-slate-400">
|
||||
默认从第 1 张开始显示,滑条控制融合体使用到第几张切片。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -740,7 +692,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">整体位姿</p>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">模型位姿</p>
|
||||
<button onClick={saveCurrentPose} className="flex items-center gap-1 text-[10px] font-bold text-blue-600 hover:text-blue-700">
|
||||
<Save size={12} />
|
||||
保存
|
||||
@@ -765,6 +717,31 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
>
|
||||
重置默认位姿
|
||||
</button>
|
||||
<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 },
|
||||
].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}
|
||||
<input
|
||||
type="range"
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
step={item.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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -831,16 +808,6 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-900 rounded-2xl shrink-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileJson size={12} className="text-blue-400" />
|
||||
<span className="text-[10px] font-bold text-white/50 uppercase tracking-widest">Metadata</span>
|
||||
</div>
|
||||
<pre className="text-[9px] text-blue-300/70 font-mono overflow-hidden">
|
||||
{`{ project: "${project?.id ?? projectId}", display: "${selectedDisplay.label}" }`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user