2026-05-07-18-11-12 增加模型库和可视化工具栏
This commit is contained in:
@@ -5,13 +5,12 @@ import {
|
||||
Settings2,
|
||||
Maximize2,
|
||||
Download,
|
||||
Layers,
|
||||
Rotate3d,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
FileJson,
|
||||
Plus,
|
||||
Play,
|
||||
Eye,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
import { DicomFusionVolume, MaskMapping, Project } from '../types';
|
||||
@@ -38,6 +37,22 @@ 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 }> = [
|
||||
{ id: 'standard', label: '标准', limit: 16000 },
|
||||
{ id: 'fine', label: '精细', limit: 36000 },
|
||||
{ id: 'ultra', label: '超精细', limit: 72000 },
|
||||
{ id: 'solid', label: '实体', limit: 200000 },
|
||||
];
|
||||
|
||||
const defaultModelPose: ModelPose = {
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
@@ -87,16 +102,19 @@ function FusionThreeView({
|
||||
project,
|
||||
volume,
|
||||
modelPose,
|
||||
onModelPoseChange,
|
||||
moduleStyles,
|
||||
detailLimit,
|
||||
solidMode,
|
||||
}: {
|
||||
project: Project;
|
||||
volume: DicomFusionVolume | null;
|
||||
modelPose: ModelPose;
|
||||
onModelPoseChange: React.Dispatch<React.SetStateAction<ModelPose>>;
|
||||
moduleStyles: Record<string, ModuleStyle>;
|
||||
detailLimit: number;
|
||||
solidMode: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const modelPoseRef = useRef(modelPose);
|
||||
const onModelPoseChangeRef = useRef(onModelPoseChange);
|
||||
const [status, setStatus] = useState('准备融合 DICOM 与 STL');
|
||||
const [loadProgress, setLoadProgress] = useState(0);
|
||||
|
||||
@@ -104,10 +122,6 @@ function FusionThreeView({
|
||||
modelPoseRef.current = modelPose;
|
||||
}, [modelPose]);
|
||||
|
||||
useEffect(() => {
|
||||
onModelPoseChangeRef.current = onModelPoseChange;
|
||||
}, [onModelPoseChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !volume) return;
|
||||
@@ -190,14 +204,14 @@ function FusionThreeView({
|
||||
});
|
||||
setLoadProgress(42);
|
||||
|
||||
const stlFiles = project.stlFiles ?? [];
|
||||
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
||||
let modelBaseScale = 1;
|
||||
let loadedModels = 0;
|
||||
let failedModels = 0;
|
||||
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||||
|
||||
Promise.allSettled(stlFiles.map((fileName, index) => (
|
||||
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=72000`)
|
||||
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error('模型预览加载失败');
|
||||
return response.json() as Promise<ModelPreviewPayload>;
|
||||
@@ -207,11 +221,18 @@ function FusionThreeView({
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
|
||||
geometry.computeVertexNormals();
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
const style = moduleStyles[fileName] ?? {
|
||||
visible: true,
|
||||
color: moduleColors[index % moduleColors.length],
|
||||
transparent: true,
|
||||
opacity: 0.72,
|
||||
roughness: 0.48,
|
||||
partId: index + 1,
|
||||
};
|
||||
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity;
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: style.color,
|
||||
transparent: true,
|
||||
opacity: materialOpacity,
|
||||
roughness: solidMode ? 0.56 : 0.48,
|
||||
metalness: 0.03,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
@@ -369,7 +390,7 @@ function FusionThreeView({
|
||||
renderer.dispose();
|
||||
container.innerHTML = '';
|
||||
};
|
||||
}, [project.id, project.stlFiles?.join('|'), volume]);
|
||||
}, [project.id, project.stlFiles?.join('|'), volume, JSON.stringify(moduleStyles), detailLimit, solidMode]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
|
||||
@@ -404,6 +425,14 @@ 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');
|
||||
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
|
||||
const [savedPoses, setSavedPoses] = useState<Array<{ id: string; name: string; pose: ModelPose }>>([
|
||||
{ id: 'default', name: '默认', pose: defaultModelPose },
|
||||
{ id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
|
||||
{ id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
|
||||
]);
|
||||
const [selectedPoseId, setSelectedPoseId] = useState('default');
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
@@ -443,6 +472,16 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
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,
|
||||
};
|
||||
});
|
||||
setModuleStyles(nextStyles);
|
||||
}).catch(() => {
|
||||
setProject(null);
|
||||
setFusionVolume(null);
|
||||
@@ -482,11 +521,48 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
...current,
|
||||
...partial,
|
||||
}));
|
||||
setSelectedPoseId('custom');
|
||||
};
|
||||
|
||||
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
||||
setModuleStyles((current) => ({
|
||||
...current,
|
||||
[fileName]: {
|
||||
visible: true,
|
||||
color: '#3b82f6',
|
||||
opacity: 0.72,
|
||||
partId: 1,
|
||||
...(current[fileName] ?? {}),
|
||||
...partial,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const updateModulePartId = (fileName: string, value: number) => {
|
||||
updateModuleStyle(fileName, { partId: clamp(Math.round(Number.isFinite(value) ? value : 1), 1, 255) });
|
||||
};
|
||||
|
||||
const saveCurrentPose = () => {
|
||||
const nextPose = {
|
||||
id: `pose-${Date.now()}`,
|
||||
name: `位姿${savedPoses.length - 2}`,
|
||||
pose: { ...modelPose },
|
||||
};
|
||||
setSavedPoses((current) => [...current, nextPose]);
|
||||
setSelectedPoseId(nextPose.id);
|
||||
};
|
||||
|
||||
const selectPose = (poseId: string) => {
|
||||
const selected = savedPoses.find((item) => item.id === poseId);
|
||||
if (!selected) return;
|
||||
setSelectedPoseId(poseId);
|
||||
setModelPose(selected.pose);
|
||||
};
|
||||
|
||||
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
|
||||
const displayStart = Math.min(sliceStart, sliceEnd);
|
||||
const displayEnd = Math.max(sliceStart, sliceEnd);
|
||||
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-6">
|
||||
@@ -540,7 +616,9 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
project={project}
|
||||
volume={fusionVolume}
|
||||
modelPose={modelPose}
|
||||
onModelPoseChange={setModelPose}
|
||||
moduleStyles={moduleStyles}
|
||||
detailLimit={selectedDisplay.limit}
|
||||
solidMode={displayLevel === 'solid'}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 rounded-3xl border border-slate-100 bg-white flex items-center justify-center text-sm text-slate-400">
|
||||
@@ -636,37 +714,122 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
<div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="px-2 shrink-0">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Layers size={18} className="text-emerald-500" />
|
||||
分割 Mask
|
||||
<Settings2 size={18} className="text-emerald-500" />
|
||||
可视化工具栏
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden flex flex-col p-4 gap-4">
|
||||
<div className="flex-1 overflow-auto space-y-2 pr-1">
|
||||
{mappings.map((mapping, index) => (
|
||||
<button
|
||||
key={mapping.maskId}
|
||||
className={`w-full flex flex-col gap-2 p-3 rounded-xl border transition-all text-left group ${
|
||||
index === 0 ? 'bg-blue-50 border-blue-200' : 'bg-slate-50 border-transparent hover:border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: mapping.color }} />
|
||||
<span className="text-xs font-bold text-slate-700">{mapping.className}</span>
|
||||
</div>
|
||||
{index === 0 && <CheckCircle2 size={14} className="text-blue-500" />}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-500 font-mono">
|
||||
<span>ID: {mapping.maskId}</span>
|
||||
<span className="font-bold text-emerald-600">Conf: 98%</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1 overflow-auto space-y-4 pr-1">
|
||||
<div>
|
||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">模型显示</p>
|
||||
<div className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
{displayOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setDisplayLevel(option.id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
|
||||
displayLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="w-full py-3 border-2 border-dashed border-slate-100 rounded-xl text-slate-400 flex items-center justify-center hover:bg-slate-50 transition-all">
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<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} />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={selectedPoseId}
|
||||
onChange={(event) => selectPose(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"
|
||||
>
|
||||
{selectedPoseId === 'custom' && <option value="custom">当前未保存位姿</option>}
|
||||
{savedPoses.map((item) => (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">构件层级</p>
|
||||
<span className="text-[10px] font-mono text-slate-400">{project?.stlFiles?.length ?? 0}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(project?.stlFiles ?? []).map((fileName, index) => {
|
||||
const style = moduleStyles[fileName] ?? {
|
||||
visible: true,
|
||||
color: moduleColors[index % moduleColors.length],
|
||||
opacity: 0.72,
|
||||
partId: index + 1,
|
||||
};
|
||||
return (
|
||||
<div key={fileName} className={`rounded-xl bg-slate-50 p-2 ${!style.visible ? 'opacity-50' : ''}`}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={style.color}
|
||||
onChange={(event) => updateModuleStyle(fileName, { color: event.target.value })}
|
||||
className="h-7 w-7 shrink-0 rounded border border-white bg-white p-0.5"
|
||||
title="模型颜色"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[10px] font-bold text-slate-700">{fileName.replace(/\.stl$/i, '')}</p>
|
||||
<label className="mt-1 flex items-center gap-1 text-[9px] font-bold text-slate-400">
|
||||
ID
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="255"
|
||||
value={style.partId}
|
||||
onChange={(event) => updateModulePartId(fileName, Number(event.target.value))}
|
||||
className="h-5 w-12 rounded border border-slate-200 bg-white px-1 font-mono text-slate-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateModuleStyle(fileName, { visible: !style.visible })}
|
||||
className={`rounded p-1 ${style.visible ? 'text-blue-500' : 'text-slate-300'} hover:bg-white`}
|
||||
title={style.visible ? '隐藏构件' : '显示构件'}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] text-slate-400">透明度</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={style.opacity}
|
||||
onChange={(event) => updateModuleStyle(fileName, { opacity: Number(event.target.value) })}
|
||||
className="min-w-0 flex-1 accent-blue-600"
|
||||
/>
|
||||
<span className="w-7 text-right text-[9px] text-slate-400">{Math.round(style.opacity * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-900 rounded-2xl shrink-0">
|
||||
@@ -675,7 +838,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
<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}", format: "nii.gz" }`}
|
||||
{`{ project: "${project?.id ?? projectId}", display: "${selectedDisplay.label}" }`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user