2026-05-24-16-28-58 修正可见构件导出与高精度映射

This commit is contained in:
2026-05-24 16:42:37 +08:00
parent 3bedf204c8
commit d9572e6966
9 changed files with 246 additions and 22 deletions

View File

@@ -142,6 +142,7 @@ const dicomVolumeCache = new Map<string, {
}>();
const modelPreviewCache = new Map<string, unknown>();
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const maxPreviewTriangles = 500000;
const defaultModelPose: ModelPoseValue = {
rotateX: 0,
rotateY: 0,
@@ -1601,6 +1602,22 @@ function parseModelPoseQuery(raw: unknown) {
}
}
function parseModuleStylesQuery(raw: unknown) {
if (typeof raw !== 'string' || !raw.trim()) {
return undefined;
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return undefined;
}
return parsed as Record<string, Partial<ModuleStyleRecord>>;
} catch {
return undefined;
}
}
function parseSegmentationScope(raw: unknown): SegmentationExportScope {
return raw === 'all' ? 'all' : 'visible';
}
@@ -1613,15 +1630,20 @@ function latestSegmentationResult(project: ProjectRecord) {
return project.segmentationResults?.[project.segmentationResults.length - 1];
}
function projectWithSegmentationResultStyles(project: ProjectRecord): ProjectRecord {
function projectWithSegmentationResultStyles(
project: ProjectRecord,
requestedModuleStyles?: Record<string, Partial<ModuleStyleRecord>>,
): ProjectRecord {
const latestResult = latestSegmentationResult(project);
if (!latestResult) {
return project;
}
const mergedStyles = buildModuleStyles(project.stlFiles, {
...(latestResult?.moduleStyles ?? {}),
...(project.moduleStyles ?? {}),
...(requestedModuleStyles ?? {}),
});
return {
...project,
moduleStyles: latestResult.moduleStyles,
moduleStyles: mergedStyles,
};
}
@@ -2385,7 +2407,7 @@ function createStlPreview(filePath: string, fileName: string, limit: number): Mo
throw new Error('当前仅支持二进制 STL 预览');
}
const sampleLimit = Math.max(100, Math.min(limit, 200000));
const sampleLimit = Math.max(100, Math.min(limit, maxPreviewTriangles));
const step = Math.max(1, Math.ceil(triangleCount / sampleLimit));
const vertices: number[] = [];
let sampledTriangles = 0;
@@ -3234,7 +3256,7 @@ async function startServer() {
const requestedTarget = targetOverride ?? String(req.query.target ?? 'segmentation');
const target = requestedTarget === 'dicom' || requestedTarget === 'pose' ? requestedTarget : 'segmentation';
const exportProject = projectWithSegmentationResultStyles(project);
const exportProject = projectWithSegmentationResultStyles(project, parseModuleStylesQuery(req.query.moduleStyles));
const latestResult = latestSegmentationResult(project);
const activePose = parseModelPoseQuery(req.query.pose) ?? latestResult?.pose;
const segmentationScope = req.query.segmentationScope === undefined
@@ -3286,7 +3308,7 @@ async function startServer() {
return;
}
const exportProject = projectWithSegmentationResultStyles(project);
const exportProject = projectWithSegmentationResultStyles(project, parseModuleStylesQuery(req.query.moduleStyles));
const latestResult = latestSegmentationResult(project);
const activePose = parseModelPoseQuery(req.query.pose) ?? latestResult?.pose;
const segmentationScope = req.query.segmentationScope === undefined

View File

@@ -981,7 +981,9 @@ export default function ProjectLibrary({
const savedSegmentationResults = selectedProject?.segmentationResults ?? [];
const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1];
const latestResultPose = latestSegmentationResult ? resultPose : modelPose;
const latestResultStyles = latestSegmentationResult?.moduleStyles ?? moduleStyles;
const latestResultStyles = latestSegmentationResult
? { ...latestSegmentationResult.moduleStyles, ...moduleStyles }
: moduleStyles;
const resultMaxSlice = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0);
const resultMappingSlice = Math.max(0, Math.min(resultMaxSlice, resultPreviewSlice));
const resultDisplayOption = reverseDisplayOptions.find((option) => option.id === 'fine') ?? reverseDisplayOptions[0];
@@ -1030,6 +1032,7 @@ export default function ProjectLibrary({
pose: latestSegmentationResult?.pose ?? modelPose,
segmentationScope: maskSegmentationScope,
segmentationExportMode: maskSegmentationExportMode,
moduleStyles,
});
window.setTimeout(() => setMaskExporting(false), 900);
setShowMaskExportMenu(false);
@@ -1163,7 +1166,10 @@ export default function ProjectLibrary({
const maxIndex = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0);
const next: Record<string, ModuleStyle> = {};
stlFiles.forEach((fileName, index) => {
next[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? selectedProject?.moduleStyles?.[fileName] ?? moduleStyles[fileName]);
next[fileName] = makeDefaultModuleStyle(index, {
...(latestResult?.moduleStyles?.[fileName] ?? {}),
...(selectedProject?.moduleStyles?.[fileName] ?? {}),
});
});
setModuleStyles(next);
setSliceIndex(0);

View File

@@ -2171,7 +2171,9 @@ export function VoxelizationMappingView({
const maxSlice = Math.max(totalSlices - 1, 0);
const safeSlice = clamp(slice, 0, maxSlice);
const stlFiles = project?.stlFiles ?? [];
const visibleModuleCount = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false).length;
const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false);
const visibleStlFileSignature = visibleStlFiles.join('|');
const visibleModuleCount = visibleStlFiles.length;
const isLibraryVariant = variant === 'library';
const activeOverlayPlacement = overlayPlacement ?? (isLibraryVariant ? 'side' : 'bottom');
@@ -2206,16 +2208,16 @@ export function VoxelizationMappingView({
}, [project?.id, project?.dicomCount, safeSlice, displayMode]);
useEffect(() => {
if (!project || !stlFiles.length) {
if (!project || !visibleStlFiles.length) {
setModelPreviews({});
setOverlayStatus('当前项目没有 STL 构件');
setOverlayStatus(stlFiles.length ? '当前没有可见 STL 构件' : '当前项目没有 STL 构件');
return;
}
let disposed = false;
setOverlayStatus('正在载入 STL 构件层级...');
Promise.allSettled(stlFiles.map((fileName) => (
getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 200000))
setOverlayStatus('正在载入可见 STL 构件层级...');
Promise.allSettled(visibleStlFiles.map((fileName) => (
getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 500000))
.then((payload) => ({ fileName, payload }))
))).then((results) => {
if (disposed) return;
@@ -2232,7 +2234,7 @@ export function VoxelizationMappingView({
return () => {
disposed = true;
};
}, [project?.id, stlFiles.join('|'), detailLimit]);
}, [project?.id, stlFiles.length, visibleStlFileSignature, detailLimit]);
useEffect(() => {
const canvas = baseCanvasRef.current;
@@ -2251,7 +2253,7 @@ export function VoxelizationMappingView({
const stats = drawVoxelOverlayLayer(
canvas,
dicomPreview,
stlFiles,
visibleStlFiles,
modelPreviews,
moduleStyles,
modelPose,
@@ -2264,7 +2266,7 @@ export function VoxelizationMappingView({
return () => window.cancelAnimationFrame(frame);
}, [
dicomPreview,
stlFiles.join('|'),
visibleStlFileSignature,
modelPreviews,
JSON.stringify(moduleStyles),
modelPose.rotateX,
@@ -2638,6 +2640,7 @@ export default function ReverseWorkspace({
pose: modelPose,
segmentationScope: segmentationExportScope,
segmentationExportMode,
moduleStyles,
});
window.setTimeout(() => setExporting(false), 900);
setShowExportMenu(false);
@@ -2947,7 +2950,10 @@ export default function ReverseWorkspace({
setPoseValueDrafts(formatPoseDraftValues(restoredPose));
const nextStyles: Record<string, ModuleStyle> = {};
(item.stlFiles ?? []).forEach((fileName, index) => {
nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? item.moduleStyles?.[fileName]);
nextStyles[fileName] = makeDefaultModuleStyle(index, {
...(latestResult?.moduleStyles?.[fileName] ?? {}),
...(item.moduleStyles?.[fileName] ?? {}),
});
});
setModuleStyles(nextStyles);
setSavedPoses(nextPoses);

View File

@@ -203,7 +203,13 @@ export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' =
triggerFileDownload(`/api/projects/${projectId}/export-mask?${params.toString()}`);
}
export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) {
function appendModuleStyles(params: URLSearchParams, moduleStyles?: Record<string, ModuleStyle>) {
if (moduleStyles) {
params.set('moduleStyles', JSON.stringify(moduleStyles));
}
}
export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode; moduleStyles?: Record<string, ModuleStyle> } = {}) {
const params = new URLSearchParams({ target, format });
if (target === 'segmentation' || target === 'pose') {
appendPose(params, options.pose);
@@ -211,11 +217,12 @@ export async function downloadProjectExport(projectId: string, target: ProjectEx
if (target === 'segmentation') {
params.set('segmentationScope', options.segmentationScope ?? 'visible');
params.set('segmentationExportMode', options.segmentationExportMode ?? 'combined');
appendModuleStyles(params, options.moduleStyles);
}
triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`);
}
export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) {
export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode; moduleStyles?: Record<string, ModuleStyle> } = {}) {
const params = new URLSearchParams({
targets: targets.join(','),
format,
@@ -223,6 +230,7 @@ export async function downloadProjectExportBundle(projectId: string, targets: Pr
segmentationExportMode: options.segmentationExportMode ?? 'combined',
});
appendPose(params, options.pose);
appendModuleStyles(params, options.moduleStyles);
triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`);
}