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;