2026-05-07-18-11-12 增加模型库和可视化工具栏
This commit is contained in:
@@ -28,7 +28,7 @@ export default function App() {
|
|||||||
|
|
||||||
// Automatically collapse main sidebar when entering Project Library or Workspace
|
// Automatically collapse main sidebar when entering Project Library or Workspace
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeView === ViewType.PROJECTS || activeView === ViewType.WORKSPACE) {
|
if (activeView === ViewType.PROJECTS || activeView === ViewType.MODELS || activeView === ViewType.WORKSPACE) {
|
||||||
setSidebarCollapsed(true);
|
setSidebarCollapsed(true);
|
||||||
} else {
|
} else {
|
||||||
setSidebarCollapsed(false);
|
setSidebarCollapsed(false);
|
||||||
@@ -106,6 +106,7 @@ export default function App() {
|
|||||||
<span className="text-slate-900 font-bold capitalize">
|
<span className="text-slate-900 font-bold capitalize">
|
||||||
{activeView === ViewType.OVERVIEW && '总体概况'}
|
{activeView === ViewType.OVERVIEW && '总体概况'}
|
||||||
{activeView === ViewType.PROJECTS && '项目库'}
|
{activeView === ViewType.PROJECTS && '项目库'}
|
||||||
|
{activeView === ViewType.MODELS && '模型库'}
|
||||||
{activeView === ViewType.WORKSPACE && '逆向工作区'}
|
{activeView === ViewType.WORKSPACE && '逆向工作区'}
|
||||||
{activeView === ViewType.SYSTEM && '系统管理工作区'}
|
{activeView === ViewType.SYSTEM && '系统管理工作区'}
|
||||||
</span>
|
</span>
|
||||||
@@ -135,6 +136,15 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeView === ViewType.MODELS && (
|
||||||
|
<ProjectLibrary
|
||||||
|
initialViewMode="model"
|
||||||
|
onReverse={(projectId) => {
|
||||||
|
setActiveProjectId(projectId);
|
||||||
|
setActiveView(ViewType.WORKSPACE);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeView === ViewType.WORKSPACE && <ReverseWorkspace projectId={activeProjectId} />}
|
{activeView === ViewType.WORKSPACE && <ReverseWorkspace projectId={activeProjectId} />}
|
||||||
{activeView === ViewType.SYSTEM && <UserManagement />}
|
{activeView === ViewType.SYSTEM && <UserManagement />}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ interface ModuleStyle {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
color: string;
|
color: string;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
|
partId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelPose {
|
interface ModelPose {
|
||||||
@@ -430,7 +431,7 @@ 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, partId: 1 },
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
).then((results) => {
|
).then((results) => {
|
||||||
@@ -492,7 +493,7 @@ function NativeStlViewer({
|
|||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
|
||||||
geometry.computeVertexNormals();
|
geometry.computeVertexNormals();
|
||||||
const style = styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true };
|
const style = styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 };
|
||||||
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity;
|
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity;
|
||||||
const mesh = new THREE.Mesh(
|
const mesh = new THREE.Mesh(
|
||||||
geometry,
|
geometry,
|
||||||
@@ -629,12 +630,18 @@ function NativeStlViewer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectLibrary({ onReverse }: { onReverse: (projId: string) => void }) {
|
export default function ProjectLibrary({
|
||||||
|
onReverse,
|
||||||
|
initialViewMode = 'dicom',
|
||||||
|
}: {
|
||||||
|
onReverse: (projId: string) => void;
|
||||||
|
initialViewMode?: 'dicom' | 'model' | 'mask';
|
||||||
|
}) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>('dicom');
|
const [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>(initialViewMode);
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
const [sliceIndex, setSliceIndex] = useState(0);
|
const [sliceIndex, setSliceIndex] = useState(0);
|
||||||
const [plane, setPlane] = useState<Plane>('axial');
|
const [plane, setPlane] = useState<Plane>('axial');
|
||||||
@@ -701,6 +708,10 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
|
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
|
||||||
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0];
|
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setViewMode(initialViewMode);
|
||||||
|
}, [initialViewMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const next: Record<string, ModuleStyle> = {};
|
const next: Record<string, ModuleStyle> = {};
|
||||||
stlFiles.forEach((fileName, index) => {
|
stlFiles.forEach((fileName, index) => {
|
||||||
@@ -708,6 +719,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
visible: true,
|
visible: true,
|
||||||
color: defaultModuleColors[index % defaultModuleColors.length],
|
color: defaultModuleColors[index % defaultModuleColors.length],
|
||||||
opacity: 0.72,
|
opacity: 0.72,
|
||||||
|
partId: index + 1,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setModuleStyles(next);
|
setModuleStyles(next);
|
||||||
@@ -767,12 +779,18 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
visible: true,
|
visible: true,
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
opacity: 0.72,
|
opacity: 0.72,
|
||||||
|
partId: 1,
|
||||||
...(prev[fileName] ?? {}),
|
...(prev[fileName] ?? {}),
|
||||||
...partial,
|
...partial,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateModulePartId = (fileName: string, value: number) => {
|
||||||
|
const nextId = Math.max(1, Math.min(255, Math.round(Number.isFinite(value) ? value : 1)));
|
||||||
|
updateModuleStyle(fileName, { partId: nextId });
|
||||||
|
};
|
||||||
|
|
||||||
const toggleAllModules = () => {
|
const toggleAllModules = () => {
|
||||||
const nextVisible = !allModulesVisible;
|
const nextVisible = !allModulesVisible;
|
||||||
setModuleStyles(prev => {
|
setModuleStyles(prev => {
|
||||||
@@ -782,6 +800,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
visible: nextVisible,
|
visible: nextVisible,
|
||||||
color: next[fileName]?.color ?? defaultModuleColors[index % defaultModuleColors.length],
|
color: next[fileName]?.color ?? defaultModuleColors[index % defaultModuleColors.length],
|
||||||
opacity: next[fileName]?.opacity ?? 0.72,
|
opacity: next[fileName]?.opacity ?? 0.72,
|
||||||
|
partId: next[fileName]?.partId ?? index + 1,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
@@ -1340,7 +1359,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
|
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
|
||||||
{stlFiles.map((fileName, i) => {
|
{stlFiles.map((fileName, i) => {
|
||||||
const name = fileName.replace(/\.stl$/i, '');
|
const name = fileName.replace(/\.stl$/i, '');
|
||||||
const style = moduleStyles[fileName] ?? { visible: true, color: defaultModuleColors[i % defaultModuleColors.length], opacity: 0.72 };
|
const style = moduleStyles[fileName] ?? { visible: true, color: defaultModuleColors[i % defaultModuleColors.length], opacity: 0.72, partId: i + 1 };
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={fileName}
|
key={fileName}
|
||||||
@@ -1355,7 +1374,21 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-[11px] font-bold text-slate-700 truncate">{name}</p>
|
<p className="text-[11px] font-bold text-slate-700 truncate">{name}</p>
|
||||||
<p className="text-[9px] text-slate-400 truncate">STL | {fileName}</p>
|
<div className="mt-0.5 flex items-center gap-2">
|
||||||
|
<p className="min-w-0 flex-1 text-[9px] text-slate-400 truncate">STL | {fileName}</p>
|
||||||
|
<label className="flex shrink-0 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))}
|
||||||
|
onBlur={(event) => updateModulePartId(fileName, Number(event.target.value))}
|
||||||
|
className="h-5 w-12 rounded border border-slate-200 bg-white px-1 text-[9px] font-mono text-slate-600 outline-none focus:border-blue-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<span className="text-[9px] text-slate-400 shrink-0">透明度</span>
|
<span className="text-[9px] text-slate-400 shrink-0">透明度</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Download,
|
Download,
|
||||||
Layers,
|
|
||||||
Rotate3d,
|
Rotate3d,
|
||||||
CheckCircle2,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
FileJson,
|
FileJson,
|
||||||
Plus,
|
|
||||||
Play,
|
Play,
|
||||||
|
Eye,
|
||||||
|
Save,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { DicomFusionVolume, MaskMapping, Project } from '../types';
|
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 = {
|
const defaultModelPose: ModelPose = {
|
||||||
rotateX: 0,
|
rotateX: 0,
|
||||||
rotateY: 0,
|
rotateY: 0,
|
||||||
@@ -87,16 +102,19 @@ function FusionThreeView({
|
|||||||
project,
|
project,
|
||||||
volume,
|
volume,
|
||||||
modelPose,
|
modelPose,
|
||||||
onModelPoseChange,
|
moduleStyles,
|
||||||
|
detailLimit,
|
||||||
|
solidMode,
|
||||||
}: {
|
}: {
|
||||||
project: Project;
|
project: Project;
|
||||||
volume: DicomFusionVolume | null;
|
volume: DicomFusionVolume | null;
|
||||||
modelPose: ModelPose;
|
modelPose: ModelPose;
|
||||||
onModelPoseChange: React.Dispatch<React.SetStateAction<ModelPose>>;
|
moduleStyles: Record<string, ModuleStyle>;
|
||||||
|
detailLimit: number;
|
||||||
|
solidMode: boolean;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const modelPoseRef = useRef(modelPose);
|
const modelPoseRef = useRef(modelPose);
|
||||||
const onModelPoseChangeRef = useRef(onModelPoseChange);
|
|
||||||
const [status, setStatus] = useState('准备融合 DICOM 与 STL');
|
const [status, setStatus] = useState('准备融合 DICOM 与 STL');
|
||||||
const [loadProgress, setLoadProgress] = useState(0);
|
const [loadProgress, setLoadProgress] = useState(0);
|
||||||
|
|
||||||
@@ -104,10 +122,6 @@ function FusionThreeView({
|
|||||||
modelPoseRef.current = modelPose;
|
modelPoseRef.current = modelPose;
|
||||||
}, [modelPose]);
|
}, [modelPose]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onModelPoseChangeRef.current = onModelPoseChange;
|
|
||||||
}, [onModelPoseChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container || !volume) return;
|
if (!container || !volume) return;
|
||||||
@@ -190,14 +204,14 @@ function FusionThreeView({
|
|||||||
});
|
});
|
||||||
setLoadProgress(42);
|
setLoadProgress(42);
|
||||||
|
|
||||||
const stlFiles = project.stlFiles ?? [];
|
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
||||||
let modelBaseScale = 1;
|
let modelBaseScale = 1;
|
||||||
let loadedModels = 0;
|
let loadedModels = 0;
|
||||||
let failedModels = 0;
|
let failedModels = 0;
|
||||||
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||||||
|
|
||||||
Promise.allSettled(stlFiles.map((fileName, index) => (
|
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) => {
|
.then((response) => {
|
||||||
if (!response.ok) throw new Error('模型预览加载失败');
|
if (!response.ok) throw new Error('模型预览加载失败');
|
||||||
return response.json() as Promise<ModelPreviewPayload>;
|
return response.json() as Promise<ModelPreviewPayload>;
|
||||||
@@ -207,11 +221,18 @@ function FusionThreeView({
|
|||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
|
||||||
geometry.computeVertexNormals();
|
geometry.computeVertexNormals();
|
||||||
const material = new THREE.MeshStandardMaterial({
|
const style = moduleStyles[fileName] ?? {
|
||||||
|
visible: true,
|
||||||
color: moduleColors[index % moduleColors.length],
|
color: moduleColors[index % moduleColors.length],
|
||||||
transparent: true,
|
|
||||||
opacity: 0.72,
|
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,
|
metalness: 0.03,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide,
|
||||||
});
|
});
|
||||||
@@ -369,7 +390,7 @@ function FusionThreeView({
|
|||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
};
|
};
|
||||||
}, [project.id, project.stlFiles?.join('|'), volume]);
|
}, [project.id, project.stlFiles?.join('|'), volume, JSON.stringify(moduleStyles), detailLimit, solidMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
|
<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 [sliceStart, setSliceStart] = useState(0);
|
||||||
const [sliceEnd, setSliceEnd] = useState(49);
|
const [sliceEnd, setSliceEnd] = useState(49);
|
||||||
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
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 [isRegistering, setIsRegistering] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
@@ -443,6 +472,16 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
setSliceStart(0);
|
setSliceStart(0);
|
||||||
setSliceEnd(end);
|
setSliceEnd(end);
|
||||||
setModelPose(defaultModelPose);
|
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(() => {
|
}).catch(() => {
|
||||||
setProject(null);
|
setProject(null);
|
||||||
setFusionVolume(null);
|
setFusionVolume(null);
|
||||||
@@ -482,11 +521,48 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
...current,
|
...current,
|
||||||
...partial,
|
...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 maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
|
||||||
const displayStart = Math.min(sliceStart, sliceEnd);
|
const displayStart = Math.min(sliceStart, sliceEnd);
|
||||||
const displayEnd = Math.max(sliceStart, sliceEnd);
|
const displayEnd = Math.max(sliceStart, sliceEnd);
|
||||||
|
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col gap-6">
|
<div className="h-full flex flex-col gap-6">
|
||||||
@@ -540,7 +616,9 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
|||||||
project={project}
|
project={project}
|
||||||
volume={fusionVolume}
|
volume={fusionVolume}
|
||||||
modelPose={modelPose}
|
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">
|
<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="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
|
||||||
<div className="px-2 shrink-0">
|
<div className="px-2 shrink-0">
|
||||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||||
<Layers size={18} className="text-emerald-500" />
|
<Settings2 size={18} className="text-emerald-500" />
|
||||||
分割 Mask
|
可视化工具栏
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</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 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">
|
<div className="flex-1 overflow-auto space-y-4 pr-1">
|
||||||
{mappings.map((mapping, index) => (
|
<div>
|
||||||
<button
|
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">模型显示</p>
|
||||||
key={mapping.maskId}
|
<div className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||||
className={`w-full flex flex-col gap-2 p-3 rounded-xl border transition-all text-left group ${
|
{displayOptions.map((option) => (
|
||||||
index === 0 ? 'bg-blue-50 border-blue-200' : 'bg-slate-50 border-transparent hover:border-slate-200'
|
<button
|
||||||
}`}
|
key={option.id}
|
||||||
>
|
onClick={() => setDisplayLevel(option.id)}
|
||||||
<div className="flex items-center justify-between">
|
className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
|
||||||
<div className="flex items-center gap-2">
|
displayLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||||
<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>
|
{option.label}
|
||||||
{index === 0 && <CheckCircle2 size={14} className="text-blue-500" />}
|
</button>
|
||||||
</div>
|
))}
|
||||||
<div className="flex items-center justify-between text-[10px] text-slate-500 font-mono">
|
</div>
|
||||||
<span>ID: {mapping.maskId}</span>
|
</div>
|
||||||
<span className="font-bold text-emerald-600">Conf: 98%</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<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">
|
<div>
|
||||||
<Plus size={18} />
|
<div className="mb-2 flex items-center justify-between">
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div className="p-3 bg-slate-900 rounded-2xl shrink-0">
|
<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>
|
<span className="text-[10px] font-bold text-white/50 uppercase tracking-widest">Metadata</span>
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-[9px] text-blue-300/70 font-mono overflow-hidden">
|
<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>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default function Sidebar({
|
|||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ id: ViewType.OVERVIEW, icon: BarChart3, label: '总体概况' },
|
{ id: ViewType.OVERVIEW, icon: BarChart3, label: '总体概况' },
|
||||||
{ id: ViewType.PROJECTS, icon: FolderRoot, label: '项目库' },
|
{ id: ViewType.PROJECTS, icon: FolderRoot, label: '项目库' },
|
||||||
|
{ id: ViewType.MODELS, icon: Box, label: '模型库' },
|
||||||
{ id: ViewType.WORKSPACE, icon: Box, label: '逆向工作区' },
|
{ id: ViewType.WORKSPACE, icon: Box, label: '逆向工作区' },
|
||||||
{ id: ViewType.SYSTEM, icon: Settings, label: '系统管理工作区' },
|
{ id: ViewType.SYSTEM, icon: Settings, label: '系统管理工作区' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export enum ViewType {
|
export enum ViewType {
|
||||||
OVERVIEW = 'overview',
|
OVERVIEW = 'overview',
|
||||||
PROJECTS = 'projects',
|
PROJECTS = 'projects',
|
||||||
|
MODELS = 'models',
|
||||||
WORKSPACE = 'workspace',
|
WORKSPACE = 'workspace',
|
||||||
SYSTEM = 'system',
|
SYSTEM = 'system',
|
||||||
}
|
}
|
||||||
|
|||||||
83
工程分析/实现方案-2026-05-07-18-11-12.md
Normal file
83
工程分析/实现方案-2026-05-07-18-11-12.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# 实现方案 - 2026-05-07-18-11-12
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
- 修复项目库构件层级眼睛按钮。
|
||||||
|
- 为构件层级增加可编辑 ID。
|
||||||
|
- 左侧导航新增 `模型库`。
|
||||||
|
- 逆向工作区将 `分割 Mask` 替换为 `可视化工具栏`,并加入模型显示、整体位姿保存/选择、构件层级。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/src/types.ts`
|
||||||
|
- `WebSite/src/App.tsx`
|
||||||
|
- `WebSite/src/components/Sidebar.tsx`
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
### 1. 左侧新增模型库
|
||||||
|
|
||||||
|
- 在 `ViewType` 中新增 `MODELS`。
|
||||||
|
- Sidebar 增加 `模型库` 导航项。
|
||||||
|
- App 中点击 `模型库` 时复用 `ProjectLibrary`,但通过 `initialViewMode="model"` 默认进入 3D 模型页。
|
||||||
|
|
||||||
|
### 2. 项目库构件层级眼睛修复与 ID
|
||||||
|
|
||||||
|
- `ModuleStyle` 增加 `partId`。
|
||||||
|
- 初始化构件层级时按文件顺序写入 `partId=index+1`。
|
||||||
|
- `toggleModuleVisibility()` 明确保留 `partId/color/opacity`,只切换 `visible`。
|
||||||
|
- 构件眼睛按钮阻止冒泡,避免父级点击干扰。
|
||||||
|
- 每个构件显示 `ID` 输入框,输入值限制为 1 到 255 的整数。
|
||||||
|
|
||||||
|
### 3. 逆向工作区可视化工具栏
|
||||||
|
|
||||||
|
- 将标题 `分割 Mask` 改为 `可视化工具栏`。
|
||||||
|
- 增加模型显示档位:标准、精细、超精细、实体。
|
||||||
|
- 增加整体位姿保存/选择:
|
||||||
|
- 默认预设:默认、俯视、侧视。
|
||||||
|
- 用户可保存当前位姿为 `位姿N`。
|
||||||
|
- 选择后更新 `modelPose`。
|
||||||
|
- 增加构件层级:
|
||||||
|
- 眼睛显示/隐藏。
|
||||||
|
- 颜色。
|
||||||
|
- 透明度。
|
||||||
|
- ID 1 到 255。
|
||||||
|
- 将逆向工作区 Three.js 模型加载逻辑接入构件样式和显示档位。
|
||||||
|
|
||||||
|
## 数据流或交互流程
|
||||||
|
|
||||||
|
1. 用户进入 `模型库` 或项目库 3D 模型页。
|
||||||
|
2. 构件样式状态按 STL 文件初始化。
|
||||||
|
3. 用户点击眼睛按钮,前端更新对应构件 `visible`,Three.js 重新加载/渲染可见构件。
|
||||||
|
4. 用户修改 ID,前端 clamp 到 1 到 255 并显示。
|
||||||
|
5. 用户进入逆向工作区,工具栏显示模型显示、位姿预设、构件层级。
|
||||||
|
6. 用户保存或选择整体位姿,融合视角模型位姿更新。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- `模型库` 复用项目库,不新增后端路由。
|
||||||
|
- 构件 ID 仅在前端状态中使用,若后续需要持久化可再扩展后端项目状态。
|
||||||
|
- 若逆向工作区工具栏过长,可后续拆成 tabs 或折叠区。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- `types.ts`:新增 ViewType。
|
||||||
|
- `Sidebar.tsx`:新增导航项。
|
||||||
|
- `App.tsx`:支持模型库视图和 ProjectLibrary 初始 tab。
|
||||||
|
- `ProjectLibrary.tsx`:构件 ID、眼睛修复、初始 viewMode 参数。
|
||||||
|
- `ReverseWorkspace.tsx`:可视化工具栏与模型样式/位姿预设。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
- 本次免二次确认,方案写入后直接执行。
|
||||||
|
|
||||||
|
## 执行结果
|
||||||
|
|
||||||
|
- 已新增左侧导航 `模型库`,默认进入项目库的 3D 模型页。
|
||||||
|
- 已为项目库构件层级增加 `ID` 输入,默认 1 到 N,输入限制为 1 到 255。
|
||||||
|
- 已修正项目库构件眼睛按钮切换时保留颜色、透明度、ID 状态。
|
||||||
|
- 已将逆向工作区 `分割 Mask` 改为 `可视化工具栏`。
|
||||||
|
- 已在可视化工具栏加入模型显示档位、整体位姿保存/选择、构件层级。
|
||||||
|
- 已将逆向工作区融合视角的 STL 加载接入构件显示、颜色、透明度和模型显示档位。
|
||||||
51
工程分析/测试方案-2026-05-07-18-11-12.md
Normal file
51
工程分析/测试方案-2026-05-07-18-11-12.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 测试方案 - 2026-05-07-18-11-12
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
1. `git status --short --branch`
|
||||||
|
2. `cd WebSite && npm run build`
|
||||||
|
3. `cd WebSite && npm run lint`
|
||||||
|
|
||||||
|
## 单元或集成测试
|
||||||
|
|
||||||
|
当前项目没有独立单元测试体系,本次采用构建、类型检查、API 冒烟和页面运行验证。
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
1. 左侧导航显示 `模型库`。
|
||||||
|
2. 点击 `模型库` 默认进入项目库 3D 模型页。
|
||||||
|
3. 项目库构件层级:
|
||||||
|
- 点击单个构件眼睛,模型可隐藏/显示。
|
||||||
|
- 顶部眼睛可全隐藏/全显示。
|
||||||
|
- 每个构件显示 `ID`。
|
||||||
|
- ID 可修改为 1 到 255。
|
||||||
|
- ID 修改为 0 时应被限制为 1。
|
||||||
|
4. 逆向工作区:
|
||||||
|
- `分割 Mask` 文案变为 `可视化工具栏`。
|
||||||
|
- 工具栏包含模型显示、整体位姿保存/选择、构件层级。
|
||||||
|
- 模型显示档位切换后场景仍可加载。
|
||||||
|
- 保存位姿后可在选择框中切回。
|
||||||
|
- 构件眼睛、颜色、透明度可影响融合视角中的模型显示。
|
||||||
|
|
||||||
|
## 医学影像数据相关边界验证
|
||||||
|
|
||||||
|
- 本次不修改 DICOM 解析和融合体接口。
|
||||||
|
- 回归确认 `/api/projects` 和 `/api/projects/head-ct-demo/dicom-fusion-volume` 可访问。
|
||||||
|
|
||||||
|
## 回归风险
|
||||||
|
|
||||||
|
- 逆向工作区模型样式状态变化会触发 Three.js 重建,可能带来短暂加载。
|
||||||
|
- 左侧新增导航项可能影响当前 header 文案和 activeView 判断。
|
||||||
|
- 项目库构件 ID 目前未持久化,刷新页面后回到默认 1 到 N。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
- 本次免二次确认。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- `npm run lint`:通过,实际执行 `tsc --noEmit`。
|
||||||
|
- `npm run build`:通过。
|
||||||
|
- 重新部署后 `curl -I http://127.0.0.1:4000/`:返回 `HTTP/1.1 200 OK`。
|
||||||
|
- `GET /api/projects`:正常返回默认项目,包含 9 个 STL 文件。
|
||||||
|
- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=0&end=10&mode=soft`:正常返回 DICOM 融合体数据。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -649,3 +649,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
融合、配准、体素化相关视图应优先使用三维数据结构,而不是二维示意图;DICOM 体数据接口必须限制切片数量和纹理尺寸,保证浏览器交互稳定;模型相对 DICOM 的调整和整体场景观察要分开管理。
|
融合、配准、体素化相关视图应优先使用三维数据结构,而不是二维示意图;DICOM 体数据接口必须限制切片数量和纹理尺寸,保证浏览器交互稳定;模型相对 DICOM 的调整和整体场景观察要分开管理。
|
||||||
|
|
||||||
|
## 2026-05-07-18-11-12 构件层级状态与可视化工具栏
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
项目库构件层级的单个眼睛按钮表现不稳定;构件缺少可编辑的 1 到 255 ID;逆向工作区仍显示 `分割 Mask`,缺少模型显示、位姿保存/选择和构件层级等可视化控制。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
构件样式状态只记录 visible/color/opacity,部分更新路径会用默认值重建对象,容易丢失扩展字段;逆向工作区三维融合视角初版只加载模型,没有把项目库中的模型显示和构件层级控制模式迁移过来。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
扩展构件样式状态为 visible/color/opacity/partId,所有更新都保留既有字段;项目库和逆向工作区均使用 1 到 255 的 ID clamp;新增 `模型库` 复用项目库并默认进入模型页;逆向工作区将中间栏改为 `可视化工具栏`,加入模型显示档位、位姿保存/选择和构件层级,并把这些状态传入 Three.js 模型加载逻辑。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
构件层级状态应作为统一结构在不同页面复用;任何新增构件字段都要检查初始化、单项更新、全局更新和默认 fallback 四条路径;逆向工作区的可视化控制应与项目库模型页保持一致,避免用户在两个页面学两套交互。
|
||||||
|
|||||||
56
工程分析/需求分析-2026-05-07-18-11-12.md
Normal file
56
工程分析/需求分析-2026-05-07-18-11-12.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 需求分析 - 2026-05-07-18-11-12
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
用户要求:
|
||||||
|
|
||||||
|
1. 修复 `项目库 - 3D模型 - 构件层级` 右侧眼睛按钮不好使的问题。
|
||||||
|
2. 左侧导航增加一个功能:`模型库`。
|
||||||
|
3. `项目库 - 3D模型 - 构件层级` 中每个层级显示 `ID: XX`,默认从 1 到 N;ID 可修改为 1 到 255 的整数,不能改成 0。
|
||||||
|
4. 逆向工作区中 `分割 Mask` 文案改为 `可视化工具栏`。
|
||||||
|
5. 在 `可视化工具栏` 中加入模型显示、整体位姿保存和选择、构件层级功能。
|
||||||
|
6. 本次需求分析、实现方案、测试方案、执行修改都不需要人工二次确认。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
- 让模型构件显示/隐藏、颜色、透明度和 ID 管理成为稳定可用的可视化控制基础。
|
||||||
|
- 将模型相关控制从项目库扩展到逆向工作区,使三维融合视角具备更完整的模型可视化工具栏。
|
||||||
|
- 让用户可以保存常用整体位姿并快速切换,降低重复调整成本。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
输入:
|
||||||
|
|
||||||
|
- 用户点击构件层级眼睛按钮。
|
||||||
|
- 用户修改构件 ID。
|
||||||
|
- 用户在逆向工作区调整模型显示、模型位姿、保存/选择位姿。
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
- 构件眼睛按钮能正确隐藏/显示对应 STL。
|
||||||
|
- 构件层级显示并可编辑 1 到 255 的 ID。
|
||||||
|
- 左侧导航出现 `模型库`,点击后进入项目库的模型页。
|
||||||
|
- 逆向工作区中出现 `可视化工具栏`,包含模型显示、整体位姿保存/选择、构件层级。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/src/types.ts`
|
||||||
|
- `WebSite/src/App.tsx`
|
||||||
|
- `WebSite/src/components/Sidebar.tsx`
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 项目库和逆向工作区各自维护构件状态,若状态结构不一致会导致行为差异。
|
||||||
|
- 构件 ID 是前端可视化 ID,不应被误认为已写入 mask 标签或后端分割 ID。
|
||||||
|
- 新增 `模型库` 导航若没有正确带入初始 tab,可能和 `项目库` 行为混淆。
|
||||||
|
- 逆向工作区可视化工具栏内容较多,需要避免布局过挤。
|
||||||
|
|
||||||
|
## 待确认问题
|
||||||
|
|
||||||
|
- 本次用户已明确免二次确认,直接执行。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
- 本次免二次确认。
|
||||||
Reference in New Issue
Block a user