2026-05-25-00-07-30 优化自动匹配对比视图与采样设置
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import express from 'express';
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import AdmZip from 'adm-zip';
|
||||
import multer from 'multer';
|
||||
import { createServer as createViteServer } from 'vite';
|
||||
@@ -747,6 +747,23 @@ function writeState(state: AppState) {
|
||||
fs.writeFileSync(statePath, JSON.stringify({ ...state, updatedAt: now() }, null, 2));
|
||||
}
|
||||
|
||||
function sendJsonPayload(req: Request, res: Response, payload: unknown) {
|
||||
const json = JSON.stringify(payload);
|
||||
const acceptsGzip = String(req.headers['accept-encoding'] ?? '').includes('gzip');
|
||||
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Vary', 'Accept-Encoding');
|
||||
if (acceptsGzip && Buffer.byteLength(json) > 64 * 1024) {
|
||||
const compressed = zlib.gzipSync(Buffer.from(json));
|
||||
res.setHeader('Content-Encoding', 'gzip');
|
||||
res.setHeader('Content-Length', String(compressed.length));
|
||||
res.send(compressed);
|
||||
return;
|
||||
}
|
||||
|
||||
res.send(json);
|
||||
}
|
||||
|
||||
interface DicomHuVolume {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -1026,9 +1043,9 @@ function transformPointForExportPose(x: number, y: number, z: number, metrics: E
|
||||
|
||||
const defaultAutoMatchWeights: AutoMatchWeights = {
|
||||
boneReward: 1,
|
||||
missPenalty: 0.45,
|
||||
movementPenalty: 0.08,
|
||||
scalePenalty: 0.12,
|
||||
missPenalty: 0.1,
|
||||
movementPenalty: 0,
|
||||
scalePenalty: 0,
|
||||
};
|
||||
const defaultAutoMatchAdjustable: AutoMatchParameterSelection = {
|
||||
translateX: true,
|
||||
@@ -3753,7 +3770,7 @@ async function startServer() {
|
||||
}
|
||||
|
||||
try {
|
||||
res.json(createStlPreview(getProjectModelFilePath(project, fileName), fileName, Number.isFinite(limit) ? limit : 5000));
|
||||
sendJsonPayload(req, res, createStlPreview(getProjectModelFilePath(project, fileName), fileName, Number.isFinite(limit) ? limit : 5000));
|
||||
} catch (error) {
|
||||
res.status(422).json({ message: error instanceof Error ? error.message : 'STL 预览失败' });
|
||||
}
|
||||
|
||||
@@ -12,15 +12,16 @@ import {
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AutoMatchCandidate,
|
||||
AutoMatchParameterKey,
|
||||
AutoMatchParameterSelection,
|
||||
AutoMatchWeights,
|
||||
ModuleStyle,
|
||||
ModelPose,
|
||||
Project,
|
||||
} from '../types';
|
||||
import { api } from '../lib/api';
|
||||
import { cn } from '../lib/utils';
|
||||
import { VoxelizationMappingView, type MappingDisplayMode } from './ReverseWorkspace';
|
||||
|
||||
const defaultModelPose: ModelPose = {
|
||||
rotateX: 0,
|
||||
@@ -44,9 +45,9 @@ const defaultAdjustable: AutoMatchParameterSelection = {
|
||||
|
||||
const defaultWeights: AutoMatchWeights = {
|
||||
boneReward: 1,
|
||||
missPenalty: 0.45,
|
||||
movementPenalty: 0.08,
|
||||
scalePenalty: 0.12,
|
||||
missPenalty: 0.1,
|
||||
movementPenalty: 0,
|
||||
scalePenalty: 0,
|
||||
};
|
||||
|
||||
const parameterOptions: Array<{ key: AutoMatchParameterKey; label: string }> = [
|
||||
@@ -64,7 +65,13 @@ const weightOptions: Array<{ key: keyof AutoMatchWeights; label: string; min: nu
|
||||
];
|
||||
|
||||
const boneNamePattern = /(rib|bone|hipbone|hip|vertebra|spine|sternum|pelvis|sacrum|costal|skull|肋|骨)/i;
|
||||
const maxManualSampleSlices = 96;
|
||||
const maxSampleSliceCount = 96;
|
||||
const defaultSampleSliceCount = 9;
|
||||
type AutoMatchMappingMode = 'selected' | 'score';
|
||||
const mappingModeOptions: Array<{ id: AutoMatchMappingMode; label: string }> = [
|
||||
{ id: 'selected', label: '所选骨骼区域' },
|
||||
{ id: 'score', label: '得分可视化' },
|
||||
];
|
||||
|
||||
function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose | null) {
|
||||
if (incomingPose) {
|
||||
@@ -78,7 +85,7 @@ function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose
|
||||
?? defaultModelPose;
|
||||
}
|
||||
|
||||
function defaultBoneFiles(project: Project | null) {
|
||||
function suggestedBoneFiles(project: Project | null) {
|
||||
const files = project?.stlFiles ?? [];
|
||||
const matched = files.filter((fileName) => boneNamePattern.test(fileName));
|
||||
return matched.length ? matched : files;
|
||||
@@ -99,57 +106,41 @@ function defaultSampleSliceNumbers(total: number) {
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function formatSampleSliceText(total: number) {
|
||||
return defaultSampleSliceNumbers(total).join(', ');
|
||||
}
|
||||
|
||||
function parseSampleSliceText(input: string, total: number) {
|
||||
function uniformSampleSliceNumbers(total: number, count: number) {
|
||||
const maxSlice = Math.max(total, 1);
|
||||
const values: number[] = [];
|
||||
const normalized = input
|
||||
.replace(/[,、;;]/g, ',')
|
||||
.replace(/\s+/g, ',')
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
normalized.forEach((token) => {
|
||||
const rangeMatch = token.match(/^(\d+)\s*[-~]\s*(\d+)(?::(\d+))?$/);
|
||||
if (rangeMatch) {
|
||||
const start = Number(rangeMatch[1]);
|
||||
const end = Number(rangeMatch[2]);
|
||||
const rawStep = Number(rangeMatch[3] ?? 1);
|
||||
const step = Math.max(1, Math.min(50, Number.isFinite(rawStep) ? Math.round(rawStep) : 1));
|
||||
const direction = start <= end ? 1 : -1;
|
||||
for (let value = start; direction > 0 ? value <= end : value >= end; value += step * direction) {
|
||||
values.push(value);
|
||||
if (values.length > maxManualSampleSlices * 2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const value = Number(token);
|
||||
if (Number.isFinite(value)) {
|
||||
values.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
const slices = [...new Set(values
|
||||
.map((value) => Math.round(value))
|
||||
.filter((value) => Number.isFinite(value))
|
||||
.map((value) => Math.max(1, Math.min(maxSlice, value))))]
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, maxManualSampleSlices);
|
||||
|
||||
return slices.length ? slices : defaultSampleSliceNumbers(total);
|
||||
const safeCount = Math.max(1, Math.min(maxSampleSliceCount, Math.round(count)));
|
||||
if (safeCount <= 1) {
|
||||
return [Math.max(1, Math.round(maxSlice / 2))];
|
||||
}
|
||||
return [...new Set(Array.from({ length: safeCount }, (_, index) => (
|
||||
Math.max(1, Math.min(maxSlice, Math.round(1 + ((maxSlice - 1) * index) / (safeCount - 1))))
|
||||
)))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function poseHasVisibleChange(base: ModelPose, next: ModelPose) {
|
||||
return parameterOptions.some((option) => Math.abs(poseDelta(base, next, option.key)) >= 0.0005);
|
||||
}
|
||||
|
||||
function buildSelectedModuleStyles(
|
||||
project: Project | null,
|
||||
selectedFiles: string[],
|
||||
mode: AutoMatchMappingMode,
|
||||
accentColor: string,
|
||||
) {
|
||||
const selected = new Set(selectedFiles);
|
||||
return (project?.stlFiles ?? []).reduce<Record<string, ModuleStyle>>((accumulator, fileName, index) => {
|
||||
const source = project?.moduleStyles?.[fileName];
|
||||
const visible = selected.has(fileName);
|
||||
accumulator[fileName] = {
|
||||
visible,
|
||||
color: visible && mode === 'score' ? accentColor : source?.color ?? ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6'][index % 5],
|
||||
opacity: visible ? (mode === 'score' ? 0.9 : source?.opacity ?? 0.72) : source?.opacity ?? 0.72,
|
||||
partId: source?.partId ?? index + 1,
|
||||
};
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
interface AutoMatchWorkspaceProps {
|
||||
projectId: string;
|
||||
initialPose?: ModelPose | null;
|
||||
@@ -164,7 +155,9 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
const [selectedBoneFiles, setSelectedBoneFiles] = useState<string[]>([]);
|
||||
const [adjustable, setAdjustable] = useState<AutoMatchParameterSelection>(defaultAdjustable);
|
||||
const [weights, setWeights] = useState<AutoMatchWeights>(defaultWeights);
|
||||
const [sampleSlicesText, setSampleSlicesText] = useState('');
|
||||
const [sampleSliceCount, setSampleSliceCount] = useState(defaultSampleSliceCount);
|
||||
const [previewSlice, setPreviewSlice] = useState(0);
|
||||
const [mappingMode, setMappingMode] = useState<AutoMatchMappingMode>('selected');
|
||||
const [iterations, setIterations] = useState(6);
|
||||
const [candidatesPerRound, setCandidatesPerRound] = useState(36);
|
||||
const [result, setResult] = useState<{
|
||||
@@ -173,7 +166,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
bestMode: string;
|
||||
bestChanged: AutoMatchParameterKey[];
|
||||
evaluated: number;
|
||||
trace: AutoMatchCandidate[];
|
||||
sampleSlices: number[];
|
||||
} | null>(null);
|
||||
const [loadingProject, setLoadingProject] = useState(true);
|
||||
@@ -212,8 +204,9 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
const incomingPose = selectedProjectId === projectId ? initialPose : null;
|
||||
setProject(nextProject);
|
||||
setBasePose(latestPoseFromProject(nextProject, incomingPose));
|
||||
setSelectedBoneFiles(defaultBoneFiles(nextProject));
|
||||
setSampleSlicesText(formatSampleSliceText(nextProject.dicomCount));
|
||||
setSelectedBoneFiles([]);
|
||||
setSampleSliceCount(defaultSampleSliceCount);
|
||||
setPreviewSlice(Math.max(0, Math.floor((nextProject.dicomCount - 1) / 2)));
|
||||
setResult(null);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
@@ -247,10 +240,21 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
[adjustable],
|
||||
);
|
||||
const parsedSampleSlices = useMemo(
|
||||
() => parseSampleSliceText(sampleSlicesText, project?.dicomCount ?? 1),
|
||||
[project?.dicomCount, sampleSlicesText],
|
||||
() => uniformSampleSliceNumbers(project?.dicomCount ?? 1, sampleSliceCount),
|
||||
[project?.dicomCount, sampleSliceCount],
|
||||
);
|
||||
const resultHasPoseChange = result ? poseHasVisibleChange(basePose, result.bestPose) : false;
|
||||
const maxPreviewSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
|
||||
const comparePose = result?.bestPose ?? basePose;
|
||||
const mappingDisplayMode: MappingDisplayMode = mappingMode === 'score' ? 'bone' : 'soft';
|
||||
const currentModuleStyles = useMemo(
|
||||
() => buildSelectedModuleStyles(project, selectedBoneFiles, mappingMode, '#f59e0b'),
|
||||
[project, selectedBoneFiles, mappingMode],
|
||||
);
|
||||
const bestModuleStyles = useMemo(
|
||||
() => buildSelectedModuleStyles(project, selectedBoneFiles, mappingMode, '#22c55e'),
|
||||
[project, selectedBoneFiles, mappingMode],
|
||||
);
|
||||
|
||||
const toggleBoneFile = (fileName: string) => {
|
||||
setSelectedBoneFiles((current) => (
|
||||
@@ -293,7 +297,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
bestMode: data.bestMode,
|
||||
bestChanged: data.bestChanged,
|
||||
evaluated: data.evaluated,
|
||||
trace: data.trace,
|
||||
sampleSlices: data.sampleSlices,
|
||||
});
|
||||
setLoadingPercent(100);
|
||||
@@ -324,8 +327,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
}
|
||||
};
|
||||
|
||||
const traceRows = result?.trace.slice(0, 10) ?? [];
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-2">
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -423,10 +424,10 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-black uppercase text-slate-400">骨骼区域</span>
|
||||
<button
|
||||
onClick={() => setSelectedBoneFiles(defaultBoneFiles(project))}
|
||||
onClick={() => setSelectedBoneFiles(suggestedBoneFiles(project))}
|
||||
className="text-xs font-black text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
重置选择
|
||||
建议选择
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-60 space-y-2 overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-2">
|
||||
@@ -455,21 +456,33 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-black uppercase text-slate-400">采样切片</span>
|
||||
<button
|
||||
onClick={() => setSampleSlicesText(formatSampleSliceText(project?.dicomCount ?? 1))}
|
||||
onClick={() => setSampleSliceCount(defaultSampleSliceCount)}
|
||||
className="text-xs font-black text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
默认采样
|
||||
默认数量
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={sampleSlicesText}
|
||||
onChange={(event) => setSampleSlicesText(event.target.value)}
|
||||
placeholder="39, 71, 102 或 39-160:5"
|
||||
className="h-20 w-full resize-none rounded-lg border border-slate-200 bg-white px-3 py-2 font-mono text-sm font-bold text-slate-700 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<div className="grid grid-cols-[1fr_72px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={maxSampleSliceCount}
|
||||
value={sampleSliceCount}
|
||||
onChange={(event) => setSampleSliceCount(Number(event.target.value))}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxSampleSliceCount}
|
||||
value={sampleSliceCount}
|
||||
onChange={(event) => setSampleSliceCount(Math.max(1, Math.min(maxSampleSliceCount, Number(event.target.value) || 1)))}
|
||||
className="h-9 rounded-md border border-slate-200 px-2 text-right font-mono text-sm font-black text-slate-700 outline-none focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs font-bold text-slate-400">
|
||||
<span>范围 1-{Math.max(project?.dicomCount ?? 1, 1)}</span>
|
||||
<span>{parsedSampleSlices.length}/{maxManualSampleSlices} 张</span>
|
||||
<span>均匀筛选 1-{Math.max(project?.dicomCount ?? 1, 1)}</span>
|
||||
<span>{parsedSampleSlices.length} 张</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -577,27 +590,6 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 grid grid-cols-4 gap-3 max-xl:grid-cols-2 max-lg:grid-cols-1">
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||
<div className="text-xs font-black text-slate-400">候选评估</div>
|
||||
<div className="mt-2 font-mono text-xl font-black text-slate-800">{result?.evaluated ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||
<div className="text-xs font-black text-slate-400">采样切片</div>
|
||||
<div className="mt-2 truncate font-mono text-sm font-black text-slate-800">
|
||||
{result?.sampleSlices.map((slice) => slice + 1).join(', ') ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||
<div className="text-xs font-black text-slate-400">骨骼构件</div>
|
||||
<div className="mt-2 font-mono text-xl font-black text-slate-800">{selectedBoneFiles.length}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3">
|
||||
<div className="text-xs font-black text-slate-400">最佳模式</div>
|
||||
<div className="mt-2 truncate text-sm font-black text-slate-800">{result?.bestMode ?? '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => void applyBestPose(false)}
|
||||
@@ -627,32 +619,77 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 overflow-hidden rounded-lg border border-slate-200">
|
||||
<div className="grid grid-cols-[72px_1fr_100px_96px_96px] bg-slate-50 px-3 py-2 text-[11px] font-black uppercase text-slate-400">
|
||||
<span>轮次</span>
|
||||
<span>模式</span>
|
||||
<span>评分</span>
|
||||
<span>命中</span>
|
||||
<span>贡献点</span>
|
||||
<div className="mt-6 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{mappingModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMappingMode(option.id)}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2 text-xs font-black transition',
|
||||
mappingMode === option.id ? 'bg-slate-900 text-white' : 'bg-white text-slate-500 hover:text-blue-700',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="font-mono text-xs font-black text-slate-500">
|
||||
切片 {previewSlice + 1} / {Math.max(project?.dicomCount ?? 1, 1)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{traceRows.length ? traceRows.map((item, index) => (
|
||||
<div
|
||||
key={`${item.iteration}-${item.mode}-${index}`}
|
||||
className="grid grid-cols-[72px_1fr_100px_96px_96px] border-t border-slate-100 px-3 py-2 text-xs font-bold text-slate-600"
|
||||
>
|
||||
<span className="font-mono">{item.iteration + 1}</span>
|
||||
<span className="truncate">{item.mode}</span>
|
||||
<span className="font-mono text-slate-900">{item.score.toFixed(4)}</span>
|
||||
<span className="font-mono text-emerald-600">{item.boneReward.toFixed(3)}</span>
|
||||
<span className="font-mono">{item.contributors}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={maxPreviewSlice}
|
||||
value={Math.min(previewSlice, maxPreviewSlice)}
|
||||
onChange={(event) => setPreviewSlice(Number(event.target.value))}
|
||||
className="mb-4 w-full accent-blue-600"
|
||||
/>
|
||||
|
||||
{selectedBoneFiles.length ? (
|
||||
<div className="grid grid-cols-2 gap-4 max-2xl:grid-cols-1">
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-black text-slate-500">当前位姿</div>
|
||||
<VoxelizationMappingView
|
||||
project={project}
|
||||
moduleStyles={currentModuleStyles}
|
||||
modelPose={basePose}
|
||||
detailLimit={360000}
|
||||
slice={Math.min(previewSlice, maxPreviewSlice)}
|
||||
totalSlices={Math.max(project?.dicomCount ?? 1, 1)}
|
||||
onSliceChange={setPreviewSlice}
|
||||
displayMode={mappingDisplayMode}
|
||||
rotation={0}
|
||||
variant="workspace"
|
||||
overlayPlacement="bottom"
|
||||
showSliceControl={false}
|
||||
/>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="px-3 py-10 text-center text-sm font-bold text-slate-400">
|
||||
尚未运行自动微调匹配
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-black text-slate-500">自动匹配结果</div>
|
||||
<VoxelizationMappingView
|
||||
project={project}
|
||||
moduleStyles={bestModuleStyles}
|
||||
modelPose={comparePose}
|
||||
detailLimit={360000}
|
||||
slice={Math.min(previewSlice, maxPreviewSlice)}
|
||||
totalSlices={Math.max(project?.dicomCount ?? 1, 1)}
|
||||
onSliceChange={setPreviewSlice}
|
||||
displayMode={mappingDisplayMode}
|
||||
rotation={0}
|
||||
variant="workspace"
|
||||
overlayPlacement="bottom"
|
||||
showSliceControl={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-white px-4 py-12 text-center text-sm font-bold text-slate-400">
|
||||
请选择骨骼区域后查看映射对比
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2227,6 +2227,7 @@ export function VoxelizationMappingView({
|
||||
toolbar,
|
||||
overlayPlacement,
|
||||
onOverlayStatsChange,
|
||||
showSliceControl = true,
|
||||
}: {
|
||||
project: Project | null;
|
||||
moduleStyles: Record<string, ModuleStyle>;
|
||||
@@ -2241,6 +2242,7 @@ export function VoxelizationMappingView({
|
||||
toolbar?: React.ReactNode;
|
||||
overlayPlacement?: 'bottom' | 'side' | 'none';
|
||||
onOverlayStatsChange?: (stats: OverlayStats, visibleModuleCount: number) => void;
|
||||
showSliceControl?: boolean;
|
||||
}) {
|
||||
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
@@ -2593,7 +2595,7 @@ export function VoxelizationMappingView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`grid min-h-0 flex-1 ${activeOverlayPlacement === 'side' ? 'grid-cols-[minmax(0,1fr)_188px]' : 'grid-cols-[minmax(0,1fr)_56px]'} bg-black`}>
|
||||
<div className={`grid min-h-0 flex-1 ${showSliceControl ? (activeOverlayPlacement === 'side' ? 'grid-cols-[minmax(0,1fr)_188px]' : 'grid-cols-[minmax(0,1fr)_56px]') : 'grid-cols-1'} bg-black`}>
|
||||
<div
|
||||
className="flex min-h-0 flex-col"
|
||||
>
|
||||
@@ -2632,21 +2634,23 @@ export function VoxelizationMappingView({
|
||||
{activeOverlayPlacement === 'bottom' && renderOverlaySummary('bottom')}
|
||||
</div>
|
||||
|
||||
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-[#0f172a] px-2 py-5">
|
||||
<div className="relative min-h-[220px] w-8 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-white/10" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliderSliceValue}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-dark-vertical-input"
|
||||
aria-label="项目库逆向分割映射视图切片导航"
|
||||
/>
|
||||
</div>
|
||||
{activeOverlayPlacement === 'side' && renderOverlaySummary('side')}
|
||||
</aside>
|
||||
{showSliceControl && (
|
||||
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-[#0f172a] px-2 py-5">
|
||||
<div className="relative min-h-[220px] w-8 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-white/10" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliderSliceValue}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-dark-vertical-input"
|
||||
aria-label="项目库逆向分割映射视图切片导航"
|
||||
/>
|
||||
</div>
|
||||
{activeOverlayPlacement === 'side' && renderOverlaySummary('side')}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2676,7 +2680,7 @@ export function VoxelizationMappingView({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_110px]">
|
||||
<div className={`grid min-h-0 flex-1 ${showSliceControl ? 'grid-cols-[minmax(0,1fr)_110px]' : 'grid-cols-1'}`}>
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div
|
||||
ref={mappingViewportRef}
|
||||
@@ -2734,47 +2738,49 @@ export function VoxelizationMappingView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{displaySliceNumber} / {Math.max(totalSlices, 1)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(1)}
|
||||
disabled={safeSlice >= maxSlice}
|
||||
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-200" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliderSliceValue}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-vertical-input"
|
||||
aria-label="逆向分割映射视图切片导航"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(-1)}
|
||||
disabled={safeSlice <= 0}
|
||||
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>顶层 1</span>
|
||||
<span className="text-blue-600">当前 {displaySliceNumber}</span>
|
||||
<span>底层 {Math.max(totalSlices, 1)}</span>
|
||||
</div>
|
||||
</aside>
|
||||
{showSliceControl && (
|
||||
<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">
|
||||
{displaySliceNumber} / {Math.max(totalSlices, 1)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(1)}
|
||||
disabled={safeSlice >= maxSlice}
|
||||
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-200" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={sliderSliceValue}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-vertical-input"
|
||||
aria-label="逆向分割映射视图切片导航"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(-1)}
|
||||
disabled={safeSlice <= 0}
|
||||
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>顶层 1</span>
|
||||
<span className="text-blue-600">当前 {displaySliceNumber}</span>
|
||||
<span>底层 {Math.max(totalSlices, 1)}</span>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user