2026-05-20-15-54-46 逆向工作区视图交互与布局优化

This commit is contained in:
2026-05-20 16:08:07 +08:00
parent 27cff93711
commit 6c9787803c
6 changed files with 514 additions and 71 deletions

View File

@@ -10,6 +10,8 @@ import {
ChevronUp,
Eye,
Layers,
Maximize2,
RefreshCcw,
Save,
Upload,
} from 'lucide-react';
@@ -70,7 +72,7 @@ const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: num
translateX: { min: -2, max: 2, step: 0.005, minus: '-X', plus: '+X' },
translateY: { min: -2, max: 2, step: 0.005, minus: '-Y', plus: '+Y' },
translateZ: { min: -2, max: 2, step: 0.005, minus: '-Z', plus: '+Z' },
scale: { min: 0.5, max: 2, step: 0.05, minus: '-S', plus: '+S' },
scale: { min: 0.5, max: 3, step: 0.05, minus: '-S', plus: '+S' },
};
const defaultModelPose: ModelPose = {
@@ -135,6 +137,36 @@ function formatPoseDraftValues(pose: ModelPose): PoseDraftValues {
}), {} as PoseDraftValues);
}
function isNinetyDegreeMultiple(value: number) {
const normalized = ((value % 90) + 90) % 90;
return Math.min(normalized, 90 - normalized) < 1e-6;
}
function isOrthogonalModelPose(pose: ModelPose) {
return isNinetyDegreeMultiple(pose.rotateX)
&& isNinetyDegreeMultiple(pose.rotateY)
&& isNinetyDegreeMultiple(pose.rotateZ);
}
function getRotatedModelSize(bounds: { min: THREE.Vector3; max: THREE.Vector3 }, pose: ModelPose) {
const center = new THREE.Vector3().addVectors(bounds.min, bounds.max).multiplyScalar(0.5);
const rotationMatrix = new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(
THREE.MathUtils.degToRad(pose.rotateX),
THREE.MathUtils.degToRad(pose.rotateY),
THREE.MathUtils.degToRad(pose.rotateZ),
));
const rotatedBox = new THREE.Box3();
[bounds.min.x, bounds.max.x].forEach((x) => {
[bounds.min.y, bounds.max.y].forEach((y) => {
[bounds.min.z, bounds.max.z].forEach((z) => {
const point = new THREE.Vector3(x, y, z).sub(center).applyMatrix4(rotationMatrix);
rotatedBox.expandByPoint(point);
});
});
});
return rotatedBox.getSize(new THREE.Vector3());
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -424,6 +456,7 @@ export function FusionThreeView({
const [loadProgress, setLoadProgress] = useState(0);
const [axisProjection, setAxisProjection] = useState<AxisProjection>(defaultAxisProjection);
const axisProjectionSignatureRef = useRef(axisProjectionSignature(defaultAxisProjection));
const resetFusionViewRef = useRef<() => void>(() => undefined);
useEffect(() => {
modelPoseRef.current = modelPose;
@@ -615,12 +648,12 @@ export function FusionThreeView({
modelPivot.add(modelBounds);
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
modelPoseGroup.position.set(0, 0, 0);
modelPivot.position.set(0, 0, dicomDepth * 0.08);
modelPivot.position.set(0, 0, 0);
setLoadProgress(100);
setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件');
});
const rootPose = {
const defaultRootPose = {
rotateX: THREE.MathUtils.degToRad(58),
rotateY: 0,
rotateZ: THREE.MathUtils.degToRad(-18),
@@ -628,6 +661,7 @@ export function FusionThreeView({
translateY: 0,
scale: 1,
};
const rootPose = { ...defaultRootPose };
const dragState = {
active: false,
mode: 'rotate' as 'rotate' | 'pan',
@@ -636,6 +670,10 @@ export function FusionThreeView({
startY: 0,
root: { ...rootPose },
};
resetFusionViewRef.current = () => {
Object.assign(rootPose, defaultRootPose);
setStatus('三维融合视角已复位');
};
const handlePointerDown = (event: PointerEvent) => {
dragState.active = true;
@@ -749,6 +787,7 @@ export function FusionThreeView({
}
});
renderer.dispose();
resetFusionViewRef.current = () => undefined;
container.innerHTML = '';
};
}, [
@@ -776,6 +815,14 @@ export function FusionThreeView({
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
</div>
<button
onClick={() => resetFusionViewRef.current()}
className="absolute right-4 top-16 z-10 flex h-8 items-center gap-1.5 rounded-xl border border-white/10 bg-black/60 px-3 text-[10px] font-bold text-white/70 shadow-lg hover:border-cyan-300/30 hover:text-cyan-100"
title="重置影像与模型融合视角位置"
>
<RefreshCcw size={13} />
</button>
<CoordinateAxesInset projection={axisProjection} />
{loadProgress < 100 && (
<div className="absolute inset-x-10 bottom-8 rounded-xl border border-white/10 bg-black/70 p-3">
@@ -952,7 +999,7 @@ function CutSectionPreview({
});
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.98;
modelPoseGroup.position.set(0, 0, 0);
modelPivot.position.set(0, 0, dicomDepth * 0.08);
modelPivot.position.set(0, 0, 0);
});
const rootPose = {
@@ -1765,6 +1812,15 @@ export function VoxelizationMappingView({
const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
const [overlayStats, setOverlayStats] = useState<OverlayStats>({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
const [mappingViewport, setMappingViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
const mappingPanRef = useRef({
active: false,
pointerId: 0,
startX: 0,
startY: 0,
offsetX: 0,
offsetY: 0,
});
const maxSlice = Math.max(totalSlices - 1, 0);
const safeSlice = clamp(slice, 0, maxSlice);
const stlFiles = project?.stlFiles ?? [];
@@ -1879,30 +1935,94 @@ export function VoxelizationMappingView({
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
};
const slicePercent = maxSlice > 0 ? (safeSlice / maxSlice) * 100 : 0;
const resetMappingViewport = () => {
setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 });
};
const handleMappingWheel = (event: React.WheelEvent<HTMLDivElement>) => {
event.preventDefault();
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
setMappingViewport((current) => ({
...current,
scale: clamp(current.scale * scaleFactor, 0.45, 6),
}));
};
const handleMappingPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
mappingPanRef.current = {
active: true,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
offsetX: mappingViewport.offsetX,
offsetY: mappingViewport.offsetY,
};
event.currentTarget.setPointerCapture(event.pointerId);
};
const handleMappingPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const dragState = mappingPanRef.current;
if (!dragState.active || dragState.pointerId !== event.pointerId) {
return;
}
setMappingViewport((current) => ({
...current,
offsetX: dragState.offsetX + event.clientX - dragState.startX,
offsetY: dragState.offsetY + event.clientY - dragState.startY,
}));
};
const stopMappingPointerDrag = (event: React.PointerEvent<HTMLDivElement>) => {
const dragState = mappingPanRef.current;
if (!dragState.active || dragState.pointerId !== event.pointerId) {
return;
}
mappingPanRef.current = { ...dragState, active: false };
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
return (
<div className="relative flex h-full min-h-[420px] flex-col overflow-hidden rounded-3xl border border-slate-900 bg-slate-950 shadow-2xl">
<div className="flex items-center justify-between gap-3 border-b border-white/10 bg-slate-950 px-4 py-3">
<div className="flex min-w-0 flex-wrap gap-2">
<span className="rounded-lg border border-white/10 bg-black/65 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-200">
<div className="relative flex h-full min-h-[520px] flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-sm">
<div className="flex items-center justify-between gap-3 border-b border-slate-100 bg-white px-4 py-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-600">
Base DICOM
</span>
<span className="rounded-lg border border-cyan-300/20 bg-cyan-950/70 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-cyan-100">
<span className="rounded-lg border border-cyan-200 bg-cyan-50 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-cyan-700">
Overlay Label Map
</span>
<span className="rounded-lg bg-slate-100 px-2.5 py-1 text-[9px] font-mono font-bold text-slate-500">
Z {safeSlice + 1}/{Math.max(totalSlices, 1)}
</span>
</div>
<div className="shrink-0 rounded-lg border border-white/10 bg-black/65 px-2.5 py-1 text-[10px] font-mono text-white/70">
Z {safeSlice + 1}/{Math.max(totalSlices, 1)}
</div>
<button
onClick={resetMappingViewport}
className="flex h-8 shrink-0 items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3 text-[10px] font-bold text-slate-600 shadow-sm hover:border-cyan-200 hover:bg-cyan-50 hover:text-cyan-700"
title="重置逆向分割映射视图位置"
>
<RefreshCcw size={13} />
</button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_76px]">
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_110px]">
<div className="flex min-h-0 flex-col">
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
<div
className={`relative min-h-0 flex-1 overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
onWheel={handleMappingWheel}
onPointerDown={handleMappingPointerDown}
onPointerMove={handleMappingPointerMove}
onPointerUp={stopMappingPointerDrag}
onPointerCancel={stopMappingPointerDrag}
>
{dicomPreview ? (
<div
className="absolute inset-0 flex items-center justify-center"
style={{ transform: `rotate(${rotation}deg)` }}
style={{
transform: `translate3d(${mappingViewport.offsetX}px, ${mappingViewport.offsetY}px, 0) rotate(${rotation}deg) scale(${mappingViewport.scale})`,
transformOrigin: 'center center',
}}
>
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
@@ -1914,10 +2034,10 @@ export function VoxelizationMappingView({
)}
</div>
<div className="border-t border-white/10 bg-slate-950 px-4 py-3">
<div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-white/70">
<span className="truncate">{overlayStatus}</span>
<span className="font-mono text-cyan-100">
<div className="border-t border-slate-100 bg-white px-4 py-3">
<div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-slate-600">
<span className="truncate">Overlay Label Map · {overlayStatus}</span>
<span className="font-mono text-cyan-700">
{overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.segmentCount} · {overlayStats.filledPixels} px
</span>
</div>
@@ -1925,17 +2045,17 @@ export function VoxelizationMappingView({
{overlayStats.modules.length ? (
<div className="grid grid-cols-2 gap-1.5 xl:grid-cols-3">
{overlayStats.modules.map((item) => (
<div key={item.fileName} className="grid grid-cols-[10px_1fr_auto] items-center gap-1 rounded-md border border-white/10 bg-white/5 px-1.5 py-1 text-[8px] font-bold text-white/70">
<span className="h-2 w-2 rounded-sm border border-white/20" style={{ backgroundColor: item.color, opacity: item.opacity }} />
<div key={item.fileName} className="grid grid-cols-[10px_1fr_auto] items-center gap-1 rounded-md border border-slate-100 bg-slate-50 px-1.5 py-1 text-[8px] font-bold text-slate-600">
<span className="h-2 w-2 rounded-sm border border-white" style={{ backgroundColor: item.color, opacity: item.opacity }} />
<span className="min-w-0 truncate">{item.name}</span>
<span className="font-mono text-cyan-100">ID {item.partId}</span>
<span className="col-start-2 font-mono text-white/35">{item.segmentCount} </span>
<span className="font-mono text-white/35">{item.filledPixels} px</span>
<span className="font-mono text-cyan-700">ID {item.partId}</span>
<span className="col-start-2 font-mono text-slate-400">{item.segmentCount} </span>
<span className="font-mono text-slate-400">{item.filledPixels} px</span>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-white/10 bg-white/5 px-2 py-1.5 text-[9px] font-bold text-white/35">
<div className="rounded-lg border border-slate-100 bg-slate-50 px-2 py-1.5 text-[9px] font-bold text-slate-400">
</div>
)}
@@ -1943,25 +2063,25 @@ export function VoxelizationMappingView({
</div>
</div>
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-slate-900/95 px-3 py-4">
<div className="w-full rounded-2xl border border-white/10 bg-slate-950/90 px-2 py-3 text-center">
<p className="text-[10px] font-bold text-slate-300">DICOM </p>
<span className="mt-1 block font-mono text-[10px] font-bold text-cyan-100">
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-slate-100 bg-white px-3 py-4">
<div className="w-full rounded-2xl border border-slate-100 bg-white px-2 py-3 text-center shadow-sm">
<p className="text-[10px] font-bold text-slate-700">DICOM </p>
<span className="mt-1 block font-mono text-[10px] font-bold text-blue-600">
{safeSlice + 1} / {Math.max(totalSlices, 1)}
</span>
</div>
<button
onClick={() => stepSlice(-1)}
disabled={safeSlice <= 0}
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-700 bg-slate-950 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
title="上一层"
>
<ChevronUp size={16} />
</button>
<div className="relative min-h-[240px] w-10 flex-1">
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-800" />
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-200" />
<div
className="absolute bottom-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-cyan-400"
className="absolute bottom-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-blue-600"
style={{ height: `${slicePercent}%` }}
/>
<input
@@ -1977,14 +2097,14 @@ export function VoxelizationMappingView({
<button
onClick={() => stepSlice(1)}
disabled={safeSlice >= maxSlice}
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-700 bg-slate-950 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
title="下一层"
>
<ChevronDown size={16} />
</button>
<div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
<span> {Math.max(totalSlices, 1)}</span>
<span className="text-cyan-200"> {safeSlice + 1}</span>
<span className="text-blue-600"> {safeSlice + 1}</span>
<span> 1</span>
</div>
</aside>
@@ -2029,7 +2149,9 @@ export default function ReverseWorkspace({
const [fusionError, setFusionError] = useState('');
const [saveStatus, setSaveStatus] = useState('');
const [exporting, setExporting] = useState(false);
const [stretchingAxis, setStretchingAxis] = useState<AxisKey | null>(null);
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
const modelBoundsCacheRef = useRef(new Map<string, { min: THREE.Vector3; max: THREE.Vector3 }>());
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
const saveToastTimerRef = useRef<number | null>(null);
@@ -2217,6 +2339,88 @@ export default function ReverseWorkspace({
return volumePayload;
};
const loadVisibleModelBounds = async () => {
if (!project) {
return null;
}
const visibleFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
const cacheKey = `${project.id}:${visibleFiles.join('|')}`;
const cached = modelBoundsCacheRef.current.get(cacheKey);
if (cached) {
return cached;
}
const modelBox = new THREE.Box3();
const results = await Promise.allSettled(visibleFiles.map((fileName) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=1000`)
.then((response) => {
if (!response.ok) {
throw new Error('模型边界载入失败');
}
return response.json() as Promise<ModelPreviewPayload>;
})
)));
results.forEach((result) => {
if (result.status !== 'fulfilled' || !result.value.bounds) {
return;
}
modelBox.expandByPoint(new THREE.Vector3(result.value.bounds.min.x, result.value.bounds.min.y, result.value.bounds.min.z));
modelBox.expandByPoint(new THREE.Vector3(result.value.bounds.max.x, result.value.bounds.max.y, result.value.bounds.max.z));
});
if (modelBox.isEmpty()) {
return null;
}
const bounds = { min: modelBox.min.clone(), max: modelBox.max.clone() };
modelBoundsCacheRef.current.set(cacheKey, bounds);
return bounds;
};
const applyModelStretchByAxis = async (axis: AxisKey) => {
if (!project || !fusionVolume) {
setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型');
return;
}
if (!isOrthogonalModelPose(modelPose)) {
setPoseImportStatus('');
setFusionError('模型拉伸仅在旋转 X/Y/Z 均为 90° 的整数倍时可用');
return;
}
setStretchingAxis(axis);
setFusionError('');
try {
const bounds = await loadVisibleModelBounds();
if (!bounds) {
throw new Error('未获取到可见 STL 构件边界');
}
const rawSize = new THREE.Vector3().subVectors(bounds.max, bounds.min);
const rotatedSize = getRotatedModelSize(bounds, modelPose);
const maxModelSize = Math.max(rawSize.x, rawSize.y, rawSize.z, 1);
const maxPhysical = Math.max(
fusionVolume.physicalSize.width,
fusionVolume.physicalSize.height,
fusionVolume.physicalSize.depth,
1,
);
const baseExtent = 4.6;
const dicomSize = {
x: (fusionVolume.physicalSize.width / maxPhysical) * baseExtent,
y: (fusionVolume.physicalSize.height / maxPhysical) * baseExtent,
z: Math.max((fusionVolume.physicalSize.depth / maxPhysical) * baseExtent, 0.18),
};
const baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92;
const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6);
const nextScale = clampPoseValue('scale', dicomSize[axis] / (rotatedAxisSize * baseScale));
updateModelPose({ scale: nextScale });
setPoseImportStatus(`已按 ${axis.toUpperCase()} 方向进行三维等比例拉伸`);
} catch (error) {
setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败');
} finally {
setStretchingAxis(null);
}
};
useEffect(() => {
api.getProject(projectId).then((item) => {
setProject(item);
@@ -2517,6 +2721,7 @@ export default function ReverseWorkspace({
const rangeEndPercent = maxSlice > 0 ? (displayEnd / maxSlice) * 100 : 0;
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0];
const stretchEnabled = Boolean(project && fusionVolume && isOrthogonalModelPose(modelPose));
return (
<div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">
@@ -2628,32 +2833,57 @@ export default function ReverseWorkspace({
</div>
</div>
<div className="min-h-[780px] lg:min-h-0 flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-4 min-h-0 flex flex-col gap-4">
<div className="min-h-[840px] flex-1 grid grid-cols-1 items-stretch gap-5 xl:grid-cols-[minmax(300px,0.82fr)_minmax(350px,0.98fr)_minmax(520px,1.32fr)] 2xl:grid-cols-[minmax(330px,0.8fr)_minmax(390px,1fr)_minmax(620px,1.45fr)]">
<div className="min-h-0 flex flex-col gap-4">
<div className="px-2 flex flex-wrap items-center justify-between gap-2 shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Rotate3d size={18} className="text-blue-500" />
</h3>
<span className="text-[10px] font-mono text-slate-400">
Layer: {displayStart + 1}-{displayEnd + 1}/{project?.dicomCount ?? 0}
</span>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<span className="text-[10px] font-mono text-slate-400">
Layer: {displayStart + 1}-{displayEnd + 1}/{project?.dicomCount ?? 0}
</span>
<div className="flex items-center gap-1 rounded-xl bg-slate-100 p-1">
<span className="hidden items-center gap-1 px-1 text-[9px] font-bold text-slate-400 2xl:flex">
<Maximize2 size={11} />
</span>
{(['x', 'y', 'z'] as AxisKey[]).map((axis) => (
<button
key={axis}
onClick={() => void applyModelStretchByAxis(axis)}
disabled={!stretchEnabled || stretchingAxis !== null}
className={`rounded-lg px-2 py-1 text-[9px] font-bold transition ${
stretchEnabled && stretchingAxis === null
? 'bg-white text-blue-600 shadow-sm hover:text-blue-700'
: 'cursor-not-allowed text-slate-300'
}`}
title={stretchEnabled ? `${axis.toUpperCase()} 方向自动等比例拉伸模型` : '仅当旋转 X/Y/Z 均为 90° 的整数倍时可用'}
>
{stretchingAxis === axis ? '...' : `${axis.toUpperCase()}拉伸`}
</button>
))}
</div>
</div>
</div>
{project ? (
<FusionThreeView
project={project}
volume={fusionVolume}
modelPose={modelPose}
moduleStyles={moduleStyles}
detailLimit={selectedDisplay.limit}
solidMode={displayLevel === 'solid'}
dicomOpacity={selectedDicomOpacity}
showBounds={showBounds}
cutEnabled={cutEnabled}
cutStart={displayStart}
cutEnd={displayEnd}
/>
<div className="min-h-0 flex-1">
<FusionThreeView
project={project}
volume={fusionVolume}
modelPose={modelPose}
moduleStyles={moduleStyles}
detailLimit={selectedDisplay.limit}
solidMode={displayLevel === 'solid'}
dicomOpacity={selectedDicomOpacity}
showBounds={showBounds}
cutEnabled={cutEnabled}
cutStart={displayStart}
cutEnd={displayEnd}
/>
</div>
) : (
<div className="flex-1 rounded-3xl border border-slate-100 bg-white flex items-center justify-center text-sm text-slate-400">
...
@@ -2714,7 +2944,7 @@ export default function ReverseWorkspace({
</div>
</div>
<div className="lg:col-span-4 min-h-0 flex flex-col gap-4 overflow-hidden">
<div className="min-h-0 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">
<Settings2 size={18} className="text-emerald-500" />
@@ -2998,7 +3228,7 @@ export default function ReverseWorkspace({
</div>
</div>
<div className="lg:col-span-4 flex flex-col gap-4 overflow-hidden">
<div className="min-h-0 flex flex-col gap-4 overflow-hidden">
<div className="px-2 flex flex-wrap items-center justify-between gap-2 shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Layers size={18} className="text-cyan-500" />
@@ -3035,17 +3265,19 @@ export default function ReverseWorkspace({
</div>
</div>
<VoxelizationMappingView
project={project}
moduleStyles={moduleStyles}
modelPose={modelPose}
detailLimit={selectedDisplay.limit}
slice={safeMappingSlice}
totalSlices={project?.dicomCount ?? 0}
onSliceChange={setMappingSlice}
displayMode={mappingDisplayMode}
rotation={mappingRotation}
/>
<div className="min-h-0 flex-1">
<VoxelizationMappingView
project={project}
moduleStyles={moduleStyles}
modelPose={modelPose}
detailLimit={selectedDisplay.limit}
slice={safeMappingSlice}
totalSlices={project?.dicomCount ?? 0}
onSliceChange={setMappingSlice}
displayMode={mappingDisplayMode}
rotation={mappingRotation}
/>
</div>
</div>
</div>
</div>

View File

@@ -142,10 +142,10 @@
.mapping-slice-vertical-input::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
background: #22d3ee;
border: 3px solid #0f172a;
background: #2563eb;
border: 3px solid #ffffff;
border-radius: 9999px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
cursor: grab;
height: 22px;
width: 22px;
@@ -158,10 +158,10 @@
}
.mapping-slice-vertical-input::-moz-range-thumb {
background: #22d3ee;
border: 3px solid #0f172a;
background: #2563eb;
border: 3px solid #ffffff;
border-radius: 9999px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
cursor: grab;
height: 16px;
width: 16px;

View File

@@ -0,0 +1,65 @@
# 实现方案:逆向工作区控件外置、视图复位与手动拉伸
实现方案文档路径:`工程分析/实现方案-2026-05-20-15-54-46.md`
## 修改目标
优化逆向工作区整体布局和交互:右侧二维映射控件白底外置,支持缩放/平移/复位;左侧三维融合视图支持复位;主布局给右侧更多空间并底部对齐;位姿工具栏新增受角度约束的模型等比例拉伸。
## 涉及路径
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/index.css`
- `工程分析/需求分析-2026-05-20-15-54-46.md`
- `工程分析/实现方案-2026-05-20-15-54-46.md`
- `工程分析/测试方案-2026-05-20-15-54-46.md`
- `工程分析/经验记录.md`
## 技术路线
1. 调整逆向工作区主网格比例,左侧略窄,右侧加宽;统一设置三列拉伸高度,减少底部错位。
2. 改造 `VoxelizationMappingView`
- 顶部图层状态改成白底信息条;
- 右侧 `DICOM 切片位置` 控件改成白底卡片,风格接近 `DICOM 切片范围`
- 保留竖向切片条并外置于影像右侧;
- 添加滚轮缩放、鼠标拖拽平移和“位置重置”按钮;
- Base DICOM 与 Overlay Canvas 共用同一 transform。
3. 改造 `FusionThreeView`
- 新增 `viewResetToken` 属性;
- 在 Three.js 场景内部保存 camera/controls 引用token 变化时恢复相机初始位置;
- 在视图右侧或角落添加“位置重置”按钮入口。
4. 位姿工具栏新增模型等比例拉伸按钮:
- 判断 `rotateX/Y/Z` 是否均为 90 度整数倍;
- 在可用时提供 X/Y/Z 方向拉伸按钮;
- 拉伸仅调整 `modelPose.scale`,作为手动校正,不默认自动执行。
5. 保持保存快照和导出语义使用现有 `modelPose.scale`,不新增后端结构。
6. 运行 lint/build部署验证并提交 Gitea。
## 执行步骤
- 阅读 `ReverseWorkspace.tsx` 中主布局、`FusionThreeView``VoxelizationMappingView`、位姿工具栏实现。
- 修改二维映射视图布局和交互状态。
- 修改三维融合视图复位接口和按钮。
- 修改主布局比例和位姿拉伸按钮。
- 运行 `npm run lint``npm run build`
- 重新部署 `tmux` 服务并验证健康接口和首页。
- 更新测试方案与经验记录。
- 精确暂存本轮相关文件commit 并推送 Gitea。
## 兼容性与回滚方案
- 二维缩放和平移仅影响 CSS transform可直接回滚到原 `rotation` transform。
- 三维复位按钮只触发相机状态,不改变数据,可安全移除。
- 手动拉伸只复用既有 `scale` 字段,不改变保存结果结构;若用户需要按轴非等比例拉伸,可后续新增独立 scaleX/Y/Z 字段。
## 预计文件变更
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/index.css`
- 本轮工程分析文档与 `工程分析/经验记录.md`
## 提交与部署策略
- 暂存本轮相关代码和工程分析文档。
- commit message 包含 `2026-05-20-15-54-46`
- 推送到 Gitea 后重新部署并验证 `http://127.0.0.1:4000/api/health` 与首页。

View File

@@ -0,0 +1,64 @@
# 测试方案:逆向工作区布局、缩放平移、复位与拉伸验证
测试方案文档路径:`工程分析/测试方案-2026-05-20-15-54-46.md`
## 静态检查
- 确认 `DICOM 切片位置` 为白底外置控件,并位于二维影像右侧。
- 确认 `Base DICOM` / `Overlay Label Map` 不再作为深色大块控件压在影像内部。
- 确认 `VoxelizationMappingView` 支持 zoom/pan 状态和位置重置。
- 确认 `FusionThreeView` 支持视图复位 token。
- 确认三维拉伸按钮仅在 XYZ 旋转均为 90 度倍数时启用。
- 确认主布局右侧视图宽度提升,三列底部更接近对齐。
## 构建检查
-`WebSite/` 执行 `npm run lint`
-`WebSite/` 执行 `npm run build`
## 关键业务场景验证
- 右侧二维影像可滚轮缩放。
- 右侧二维影像可鼠标拖拽平移。
- 点击“位置重置”后二维影像恢复居中、1 倍缩放。
- 点击三维融合视图复位按钮后相机恢复默认视角。
- 旋转角度不是 90 度倍数时,拉伸按钮不可用。
- 旋转角度满足 90 度倍数时,可触发 X/Y/Z 方向等比例拉伸并更新模型 scale。
## 医学影像数据相关边界验证
- 不修改 DICOM/STL 原始数据。
- 不改变切片映射算法和导出接口。
- 二维缩放和平移只影响浏览显示,不改变 Label Map 坐标。
- 手动拉伸属于位姿参数调整,保存/导出沿用当前位姿。
## 部署验证
- 验证 `http://127.0.0.1:4000/api/health`
- 验证 `http://127.0.0.1:4000/` 返回 200。
## Git/Gitea 备份验证
- commit message 包含 `2026-05-20-15-54-46`
- 推送 Gitea 成功后记录 commit。
- 确认未暂存历史删除状态、软著材料和运行态文件。
## 风险与回归关注点
- 右侧控件白底化后要保持与深色影像区边界清晰。
- CSS transform 顺序需要同时覆盖 Base DICOM 与 Overlay避免错位。
- Three.js 复位不能打断现有 OrbitControls。
- 当前实现为等比例 `scale` 调整,不提供非等比轴向拉伸。
## 执行结果
- `npm run lint`通过TypeScript 无报错。
- `npm run build`通过Vite 完成生产构建;仅保留当前项目已有的大 chunk 体积提示。
- 静态确认:右侧 `DICOM 切片位置` 已改为白底外置卡片,`Overlay Label Map` 状态与构件列表已位于影像外部。
- 静态确认:`VoxelizationMappingView` 已加入滚轮缩放、鼠标拖拽平移和 `位置重置`
- 静态确认:`FusionThreeView` 已加入 `位置重置`,三维模型默认 Z 偏移已取消。
- 静态确认:主布局改为左窄、中等、右宽的三列比例,并用 `flex-1` 包裹三维/二维视图以改善底部对齐。
- 静态确认:已加入 X/Y/Z 等比例自动拉伸按钮,旋转 X/Y/Z 均为 90° 整数倍且数据加载完成时可用。
- 部署验证:已重建 `tmux` 会话 `revoxelseg-dicom`,执行 `npm run serve -- --host 0.0.0.0 --port 4000`
- `curl -fsS http://127.0.0.1:4000/api/health`:通过,返回 `{"ok":true,"service":"revoxelseg-dicom",...}`
- `curl -I -fsS http://127.0.0.1:4000/`:通过,返回 `HTTP/1.1 200 OK`

View File

@@ -1315,3 +1315,21 @@ C. 解决问题方案
D. 后续如何避免问题
医学影像主画布应优先保持无遮挡,状态、统计、导航控件默认放在画布外侧。若必须悬浮在影像上,只能使用小尺寸状态标识,并在提交前检查是否遮挡解剖结构或分割边界。
## 2026-05-20-15-54-46 视图交互状态与位姿参数要分层处理
A. 具体问题
用户要求右侧二维影像支持滚轮缩放、拖拽移动、位置重置,同时三维融合视图也需要视角复位,并增加按方向的三维等比例拉伸。如果把这些交互都写进位姿数据,容易把临时浏览状态误保存进项目结果。
B. 产生问题原因
二维缩放/平移、三维相机复位属于查看视口状态;模型平移、旋转、缩放属于会影响分割映射和导出的位姿参数。两类状态此前都在可视化层发生,如果不区分,很容易导致“只是看大一点”变成“改变了结果”。
C. 解决问题方案
二维映射视图新增独立 `mappingViewport`,只通过 CSS transform 同步作用于 Base DICOM 与 Overlay Canvas不写入保存结果三维融合视图用内部 `rootPose` 复位浏览相机,也不改变 `modelPose`。模型等比例拉伸则明确更新 `modelPose.scale`,并限制在旋转 X/Y/Z 均为 90° 整数倍时才可触发。
D. 后续如何避免问题
新增影像查看交互时必须先判断它属于“浏览视口”还是“结果位姿”。浏览视口状态默认不进入保存和导出;只有会改变模型与 DICOM 空间关系的参数才进入 `modelPose`、保存快照和导出数据。

View File

@@ -0,0 +1,64 @@
# 需求分析:逆向工作区视图比例、白底控件、平移缩放与三维拉伸
开始时间:`2026-05-20-15-54-46`
## 原始需求摘要
用户要求继续优化逆向工作区:
1. “DICOM 切片位置”展示效果要类似“DICOM 切片范围”,白底、格式统一,不放入 DICOM 图像中,放到图片右侧;`Overlay Label Map` 也不要放在图片中。
2. 左侧“模型显示/影像与模型融合视角”略微变小、变窄,为右侧视图留出更多空间。
3. 右侧 DICOM 影像支持鼠标滚轮放大缩小、鼠标拖拽移动;“逆向分割映射视图”增加位置重置按钮;“影像与模型融合视角”右侧也增加位置重置按钮。
4. 逆向工作区底部三列不平行,需要拉长“逆向分割映射视图”和“可视化工具栏”,让底部对齐。
5. “影像与模型融合视角”右侧增加自动拉伸按钮;仅当旋转 XYZ 都为 0 或 90 的整数倍时可用;按特定方向对模型做三维等比例拉伸。当前初始状态若存在自动拉伸导致 DICOM 与模型顶/底不在同一平面,后续以手动选择拉伸方向为准,不再默认自动拉伸。
## 业务目标
- 让右侧二维映射视图的控件与中部工具栏控件风格一致,避免深色大面板压缩或遮挡影像。
- 增强二维映射视图审查能力,支持缩放、拖拽和平移复位。
- 增强三维融合视图审查能力,提供视角复位和受约束的模型等比例拉伸操作。
- 优化三列布局比例和底部对齐,使右侧影像审查区更宽、更稳。
## 输入与输出
输入:
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/index.css`
- 复用逆向视图的 `WebSite/src/components/ProjectLibrary.tsx`
输出:
- 右侧“DICOM 切片位置”改为白底控制卡片,放在影像右侧外部。
- `Base DICOM` / `Overlay Label Map` 信息移出影像内部,以独立白底图层状态展示。
- 二维映射视图支持滚轮缩放、拖拽平移、位置复位。
- 三维融合视图支持位置复位。
- 模型位姿增加手动三维等比例拉伸控制,旋转角度满足 90 度倍数时可用。
- 左中右布局比例调整,右侧视图获得更多宽度,底部保持对齐。
## 影响范围
- 逆向工作区三列主布局。
- `FusionThreeView` 三维场景交互与工具按钮。
- `VoxelizationMappingView` 二维映射视图交互、控件样式和白底布局。
- 项目库中复用的两类视图会同步获得复位/缩放等能力。
## 关键约束
- 不改变 DICOM/STL 原始数据和导出算法。
- 不默认自动拉伸模型,避免再次引入初始错位;自动拉伸由用户手动触发。
- 三维拉伸只在 XYZ 旋转均为 90 度整数倍时启用。
- 二维影像缩放和平移只影响浏览视口,不改变切片索引、分割映射和导出结果。
- 不提交历史删除状态、软著文档和无关运行态文件。
## 风险点
- Three.js 场景复位需要在组件内部暴露状态触发,避免影响原有 OrbitControls。
- 二维 Canvas 缩放和平移需要同时作用于 Base DICOM 和 Overlay Label Map保持叠加严格同步。
- 三维模型拉伸方向若与医学物理坐标不完全一致,需要在 UI 上做成“手动校正”而非默认自动修正。
- 调整三列比例可能影响较小视口下的滚动和可用宽度。
## 默认假设
- “模型显示”指左侧“影像与模型融合视角”的三维模型/融合视图区域。
- “自动拉伸”本轮实现为用户手动点击的等比例缩放操作,按 X/Y/Z 方向选择基于 DICOM 体范围估算缩放,不在初始加载时自动执行。