2026-05-20-22-35-42 导入资产与分割导出优化

This commit is contained in:
2026-05-20 22:58:39 +08:00
parent ec4cb1eae7
commit 67295ddd9f
9 changed files with 660 additions and 77 deletions

View File

@@ -22,10 +22,11 @@ import {
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectExportTarget } from '../lib/api';
import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectAssetImportKind, ProjectExportTarget, SegmentationExportMode } from '../lib/api';
import {
FusionThreeView,
VoxelizationMappingView,
clearCachedProjectAssets,
getCachedDicomFusionVolume,
getCachedDicomPreview,
getCachedModelPreview,
@@ -63,14 +64,18 @@ type ModelPoseKey = keyof ModelPose;
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
{ id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' },
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
{ id: 'stl', label: 'STL 原始模型', description: '原始三维构件' },
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
];
const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [
{ id: 'visible', label: '可见类别', description: '仅导出当前显示构件' },
{ id: 'all', label: '所有类别', description: '包含隐藏构件' },
];
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
{ id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' },
];
const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [
{ id: 'standard', label: '标准', limit: 16000 },
{ id: 'fine', label: '精细', limit: 36000 },
@@ -117,6 +122,17 @@ function formatPoseCompactValue(value: number, digits = 2) {
return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0';
}
async function fileToBase64(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = '';
const chunkSize = 0x8000;
for (let index = 0; index < bytes.length; index += chunkSize) {
const chunk = bytes.subarray(index, index + chunkSize);
binary += String.fromCharCode(...chunk);
}
return window.btoa(binary);
}
function drawFallbackModelPreview(
canvas: HTMLCanvasElement,
previews: Array<{ payload: ModelPreviewPayload; style: ModuleStyle }>,
@@ -676,13 +692,17 @@ export default function ProjectLibrary({
const [actionMessage, setActionMessage] = useState('');
const [showMaskExportMenu, setShowMaskExportMenu] = useState(false);
const [maskExportSelection, setMaskExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
dicom: true,
dicom: false,
segmentation: true,
pose: true,
stl: false,
});
const [maskSegmentationScope, setMaskSegmentationScope] = useState<SegmentationExportScope>('visible');
const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState<SegmentationExportMode>('combined');
const [maskExporting, setMaskExporting] = useState(false);
const [assetImporting, setAssetImporting] = useState(false);
const importInputRef = useRef<HTMLInputElement | null>(null);
const importKindRef = useRef<ProjectAssetImportKind>('dicom');
const sliceRepeatRef = useRef<number | null>(null);
const dicomRequestRef = useRef(0);
const preloadedProjectIdsRef = useRef(new Set<string>());
@@ -804,6 +824,7 @@ export default function ProjectLibrary({
await downloadProjectExportBundle(selectedProject.id, selectedTargets, 'nii.gz', {
pose: latestSegmentationResult?.pose ?? modelPose,
segmentationScope: maskSegmentationScope,
segmentationExportMode: maskSegmentationExportMode,
});
window.setTimeout(() => setMaskExporting(false), 900);
setShowMaskExportMenu(false);
@@ -813,6 +834,66 @@ export default function ProjectLibrary({
}
};
const triggerProjectAssetImport = () => {
if (!selectedProject || viewMode === 'mask' || assetImporting) {
return;
}
const kind: ProjectAssetImportKind = viewMode === 'model' ? 'stl' : 'dicom';
const input = importInputRef.current;
if (!input) {
setActionMessage('导入控件尚未就绪,请稍后重试');
return;
}
importKindRef.current = kind;
input.value = '';
input.accept = kind === 'dicom' ? '.dcm,.dicom,application/dicom' : '.stl';
input.multiple = true;
input.click();
};
const handleProjectAssetImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!selectedProject) {
return;
}
const files = Array.from(event.target.files ?? []);
event.target.value = '';
if (!files.length) {
return;
}
const kind = importKindRef.current;
setAssetImporting(true);
setActionMessage(kind === 'dicom' ? '正在导入 DICOM 影像...' : '正在导入 STL 模型...');
try {
const payload = await Promise.all(files.map(async (file) => ({
name: file.name,
data: await fileToBase64(file),
})));
const updated = await api.importProjectAssets(selectedProject.id, kind, payload);
clearCachedProjectAssets(updated.id);
preloadedProjectIdsRef.current.delete(updated.id);
setSelectedProject(updated);
setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item)));
const latestResult = updated.segmentationResults?.[updated.segmentationResults.length - 1];
const nextStyles: Record<string, ModuleStyle> = {};
(updated.stlFiles ?? []).forEach((fileName, index) => {
nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? updated.moduleStyles?.[fileName]);
});
setModuleStyles(nextStyles);
setModelPose(latestResult?.pose ?? defaultModelPose);
setResultPose(latestResult?.pose ?? defaultModelPose);
setSliceIndex(0);
setDicomPreview(null);
setDicomError('');
setResultFusionVolume(null);
setActionMessage(kind === 'dicom' ? `已导入 ${updated.dicomCount} 张 DICOM 影像` : `已导入 ${updated.modelCount ?? 0} 个 STL 模型`);
} catch (error) {
setActionMessage(error instanceof Error ? error.message : '项目资产导入失败');
} finally {
setAssetImporting(false);
}
};
useEffect(() => {
setViewMode(initialViewMode);
}, [initialViewMode]);
@@ -1204,6 +1285,12 @@ export default function ProjectLibrary({
<div className="flex-1 flex flex-col gap-6 overflow-hidden">
{selectedProject ? (
<>
<input
ref={importInputRef}
type="file"
className="hidden"
onChange={handleProjectAssetImport}
/>
<div className="flex items-center justify-between">
<div className="flex bg-slate-100 p-1 rounded-xl">
{tabs.map((tab) => (
@@ -1226,8 +1313,12 @@ export default function ProjectLibrary({
<RotateCw size={18} />
</button>
{viewMode !== 'mask' && (
<button className="bg-slate-800 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-slate-700 transition-all">
<Upload size={18} />
<button
onClick={triggerProjectAssetImport}
disabled={assetImporting}
className="bg-slate-800 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-slate-700 transition-all disabled:opacity-50"
>
<Upload size={18} /> {assetImporting ? '导入中' : '导入'}
</button>
)}
</div>
@@ -1292,7 +1383,9 @@ export default function ProjectLibrary({
{dicomPreview ? (
<DicomCanvas preview={dicomPreview} rotation={rotation} />
) : (
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">{dicomError || '正在解析 DICOM 像素...'}</p>
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">
{selectedProject.dicomCount ? dicomError || '正在解析 DICOM 像素...' : '请导入DICOM影像'}
</p>
)}
{isSliceChanging && dicomPreview && (
<span className="absolute right-3 top-3 rounded-md bg-blue-500/20 px-2 py-1 text-[9px] font-bold text-blue-200 backdrop-blur-sm">
@@ -1301,8 +1394,8 @@ export default function ProjectLibrary({
)}
</div>
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
<span> {sliceIndex + 1} / {dicomPreview?.total ?? selectedProject.dicomCount} </span>
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
<span> {selectedProject.dicomCount ? sliceIndex + 1 : 0} / {dicomPreview?.total ?? selectedProject.dicomCount} </span>
</div>
</div>
{/* Right: Vertical Progress Bar */}
@@ -1389,6 +1482,13 @@ export default function ProjectLibrary({
pose={modelPose}
onPoseChange={setModelPose}
/>
{!stlFiles.length && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<p className="rounded-2xl border border-slate-200 bg-white/85 px-5 py-3 text-xs font-bold text-slate-500 shadow-sm">
STL模型
</p>
</div>
)}
<div className="absolute bottom-4 left-4 text-slate-400 font-mono text-[10px]">
MODEL PATH: {selectedProject.modelPath} | STL: {selectedProject.modelCount ?? 0} | {selectedSolidity.label}
</div>
@@ -1724,6 +1824,27 @@ export default function ProjectLibrary({
</button>
))}
</div>
<div className="mt-2 border-t border-emerald-100 pt-2">
<p className="mb-2 text-[10px] font-bold text-emerald-800"></p>
<div className="grid grid-cols-2 gap-1.5">
{segmentationExportModeOptions.map((option) => (
<button
key={option.id}
onClick={() => setMaskSegmentationExportMode(option.id)}
className={`rounded-lg px-2 py-1.5 text-left transition ${
maskSegmentationExportMode === option.id
? 'bg-slate-900 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-emerald-100'
}`}
>
<span className="block text-[10px] font-bold">{option.label}</span>
<span className={`block text-[9px] ${maskSegmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
{option.description}
</span>
</button>
))}
</div>
</div>
</div>
)}
<button

View File

@@ -16,7 +16,7 @@ import {
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportScope } from '../lib/api';
import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportMode, SegmentationExportScope } from '../lib/api';
export interface ModelPreviewPayload {
fileName: string;
@@ -100,14 +100,18 @@ const defaultSavedPoses: SavedModelPose[] = [
];
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
{ id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' },
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
{ id: 'stl', label: 'STL 原始模型', description: '原始三维构件' },
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
];
const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [
{ id: 'visible', label: '可见类别', description: '仅导出当前显示构件' },
{ id: 'all', label: '所有类别', description: '包含隐藏构件' },
];
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
{ id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' },
];
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const fusionBaseExtent = 4.6;
const axisInsetLength = 17;
@@ -177,6 +181,16 @@ export function getCachedModelPreview(projectId: string, fileName: string, limit
);
}
export function clearCachedProjectAssets(projectId: string) {
[dicomPreviewCache, dicomFusionVolumeCache, modelPreviewCache].forEach((cache) => {
[...cache.keys()].forEach((key) => {
if (key.startsWith(`${projectId}:`)) {
cache.delete(key);
}
});
});
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
@@ -2056,7 +2070,7 @@ export function VoxelizationMappingView({
const renderOverlaySummary = (placement: 'bottom' | 'side') => (
<div className={`${placement === 'side' ? 'w-full rounded-2xl border border-white/10 bg-black/40 p-2' : 'border-t border-white/10 bg-[#030712] px-4 py-3'}`}>
<div className={`mb-2 flex gap-2 text-[10px] font-bold text-white/60 ${placement === 'side' ? 'flex-col' : 'items-center justify-between'}`}>
<span className="truncate">Overlay Label Map · {overlayStatus}</span>
<span className="truncate">Overlay Label Map</span>
<span className="font-mono text-cyan-100">
{overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.segmentCount} · {overlayStats.filledPixels} px
</span>
@@ -2219,7 +2233,7 @@ export function VoxelizationMappingView({
<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="truncate">Overlay Label Map</span>
<span className="font-mono text-cyan-700">
{overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.segmentCount} · {overlayStats.filledPixels} px
</span>
@@ -2321,12 +2335,13 @@ export default function ReverseWorkspace({
const [selectedPoseId, setSelectedPoseId] = useState('default');
const [showExportMenu, setShowExportMenu] = useState(false);
const [exportSelection, setExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
dicom: true,
dicom: false,
segmentation: true,
pose: true,
stl: false,
});
const [segmentationExportScope, setSegmentationExportScope] = useState<SegmentationExportScope>('visible');
const [segmentationExportMode, setSegmentationExportMode] = useState<SegmentationExportMode>('combined');
const [project, setProject] = useState<Project | null>(null);
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
const [fusionError, setFusionError] = useState('');
@@ -2347,6 +2362,7 @@ export default function ReverseWorkspace({
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
const saveToastTimerRef = useRef<number | null>(null);
const savedWorkspaceSnapshotRef = useRef('');
const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false });
const handleExportSelected = async () => {
const selectedItems = exportOptions
@@ -2363,6 +2379,7 @@ export default function ReverseWorkspace({
await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', {
pose: modelPose,
segmentationScope: segmentationExportScope,
segmentationExportMode,
});
window.setTimeout(() => setExporting(false), 900);
setShowExportMenu(false);
@@ -2552,7 +2569,7 @@ export default function ReverseWorkspace({
return bounds;
};
const applyModelStretchByAxis = async (axis: AxisKey) => {
const applyModelStretchByAxis = async (axis: AxisKey, options: { silentInitial?: boolean } = {}) => {
if (!project || !fusionVolume) {
setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型');
return;
@@ -2588,8 +2605,23 @@ export default function ReverseWorkspace({
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 });
const nextPose = { ...modelPose, scale: nextScale };
updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true });
setPoseImportStatus(`已按 ${axis.toUpperCase()} 方向进行三维等比例拉伸`);
if (options.silentInitial) {
savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
modelPose: nextPose,
segmentationExportScope,
moduleStyles,
sliceStart,
sliceEnd,
mappingSlice,
displayLevel,
dicomOpacityLevel,
showBounds,
cutEnabled,
});
}
} catch (error) {
setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败');
} finally {
@@ -2620,6 +2652,7 @@ export default function ReverseWorkspace({
const nextPoses = item.modelPoses?.length ? item.modelPoses : defaultSavedPoses;
const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0];
const restoredPose = latestResult?.pose ?? preferredPose?.pose ?? defaultModelPose;
initialZStretchRef.current = { projectId: item.id, pending: !latestResult };
setModelPose(restoredPose);
setPoseValueDrafts(formatPoseDraftValues(restoredPose));
const nextStyles: Record<string, ModuleStyle> = {};
@@ -2715,7 +2748,7 @@ export default function ReverseWorkspace({
return clamp(value, limit.min, limit.max);
};
const updateModelPose = (partial: Partial<ModelPose>) => {
const updateModelPose = (partial: Partial<ModelPose>, options: { markCustom?: boolean; keepStatus?: boolean } = {}) => {
setModelPose((current) => {
const next = { ...current };
modelPoseKeys.forEach((key) => {
@@ -2726,8 +2759,12 @@ export default function ReverseWorkspace({
});
return next;
});
setSelectedPoseId('custom');
setPoseImportStatus('');
if (options.markCustom !== false) {
setSelectedPoseId('custom');
}
if (!options.keepStatus) {
setPoseImportStatus('');
}
};
const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
@@ -3021,6 +3058,25 @@ export default function ReverseWorkspace({
mappingDisplayMode,
]);
useEffect(() => {
if (!project || !fusionVolume || !workspaceLoadState.ready) {
return;
}
const stretchState = initialZStretchRef.current;
if (stretchState.projectId !== project.id || !stretchState.pending || !isOrthogonalModelPose(modelPose)) {
return;
}
initialZStretchRef.current = { projectId: project.id, pending: false };
void applyModelStretchByAxis('z', { silentInitial: true });
}, [
project?.id,
fusionVolume,
workspaceLoadState.ready,
modelPose.rotateX,
modelPose.rotateY,
modelPose.rotateZ,
]);
if (!workspaceLoadState.ready) {
return (
<div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2">
@@ -3166,6 +3222,27 @@ export default function ReverseWorkspace({
</button>
))}
</div>
<div className="mt-2 border-t border-emerald-100 pt-2">
<p className="mb-2 text-[10px] font-bold text-emerald-800"></p>
<div className="grid grid-cols-2 gap-1.5">
{segmentationExportModeOptions.map((option) => (
<button
key={option.id}
onClick={() => setSegmentationExportMode(option.id)}
className={`rounded-lg px-2 py-1.5 text-left transition ${
segmentationExportMode === option.id
? 'bg-slate-900 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-emerald-100'
}`}
>
<span className="block text-[10px] font-bold">{option.label}</span>
<span className={`block text-[9px] ${segmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
{option.description}
</span>
</button>
))}
</div>
</div>
</div>
)}
<button

View File

@@ -132,6 +132,10 @@ export default function UserManagement() {
setMessage('两次输入的密码不一致');
return;
}
if ((formMode === 'create' || formMode === 'edit') && users.some((user) => user.id !== form.id && user.account === account)) {
setMessage('账号已存在,请更换账号');
return;
}
setSaving(true);
try {
@@ -150,7 +154,8 @@ export default function UserManagement() {
closeForm();
await refreshUsers();
} catch (error) {
setMessage(error instanceof Error ? error.message : '用户保存失败');
const message = error instanceof Error ? error.message : '用户保存失败';
setMessage(message === '账号已存在' ? '账号已存在,请更换账号' : message);
setSaving(false);
}
};

View File

@@ -1,6 +1,8 @@
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationDicomOpacityLevel, SegmentationDisplayLevel, SegmentationExportScope, SessionState, UserRecord } from '../types';
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
export type SegmentationExportMode = 'combined' | 'separate';
export type ProjectAssetImportKind = 'dicom' | 'stl';
export type { SegmentationExportScope } from '../types';
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
@@ -63,6 +65,11 @@ export const api = {
method: 'PATCH',
body: JSON.stringify({ modelPoses }),
}),
importProjectAssets: (projectId: string, kind: ProjectAssetImportKind, files: Array<{ name: string; data: string }>) =>
request<Project>(`/api/projects/${projectId}/import-assets`, {
method: 'POST',
body: JSON.stringify({ kind, files }),
}),
saveProjectSegmentationResult: (
projectId: string,
payload: {
@@ -131,22 +138,24 @@ 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 } = {}) {
export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) {
const params = new URLSearchParams({ target, format });
if (target === 'segmentation' || target === 'pose') {
appendPose(params, options.pose);
}
if (target === 'segmentation') {
params.set('segmentationScope', options.segmentationScope ?? 'visible');
params.set('segmentationExportMode', options.segmentationExportMode ?? 'combined');
}
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 } = {}) {
export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) {
const params = new URLSearchParams({
targets: targets.join(','),
format,
segmentationScope: options.segmentationScope ?? 'visible',
segmentationExportMode: options.segmentationExportMode ?? 'combined',
});
appendPose(params, options.pose);
triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`);