2026-06-23-13-38-48 新增逆向工作区单轴拉伸

This commit is contained in:
2026-06-23 13:54:15 +08:00
parent 1e044fb349
commit 5a9d09a5bf
9 changed files with 401 additions and 42 deletions

View File

@@ -33,6 +33,9 @@ interface ModelPoseValue {
translateY: number;
translateZ: number;
scale: number;
scaleX: number;
scaleY: number;
scaleZ: number;
flipX: boolean;
flipY: boolean;
flipZ: boolean;
@@ -179,6 +182,9 @@ const defaultModelPose: ModelPoseValue = {
translateY: 0,
translateZ: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
flipX: false,
flipY: false,
flipZ: false,
@@ -188,6 +194,9 @@ const modelPoseValuePrecision: Partial<Record<keyof ModelPoseValue, number>> = {
translateY: 3,
translateZ: 3,
scale: 3,
scaleX: 3,
scaleY: 3,
scaleZ: 3,
};
interface DicomAttributes {
@@ -494,6 +503,9 @@ function normalizeModelPoseValue(value: Partial<ModelPoseValue> | undefined): Mo
translateY: read('translateY', defaultModelPose.translateY, -2, 2),
translateZ: read('translateZ', defaultModelPose.translateZ, -2, 2),
scale: read('scale', defaultModelPose.scale, 0.5, 3),
scaleX: read('scaleX', defaultModelPose.scaleX, 0.2, 8),
scaleY: read('scaleY', defaultModelPose.scaleY, 0.2, 8),
scaleZ: read('scaleZ', defaultModelPose.scaleZ, 0.2, 8),
flipX: readBoolean('flipX', defaultModelPose.flipX),
flipY: readBoolean('flipY', defaultModelPose.flipY),
flipZ: readBoolean('flipZ', defaultModelPose.flipZ),
@@ -1030,9 +1042,9 @@ function transformPointForExportPose(x: number, y: number, z: number, metrics: E
y: afterX.y,
z: -afterX.x * sinY + afterX.z * cosY,
};
px = afterY.x * cosZ - afterY.y * sinZ;
py = afterY.x * sinZ + afterY.y * cosZ;
pz = afterY.z;
px = (afterY.x * cosZ - afterY.y * sinZ) * pose.scaleX;
py = (afterY.x * sinZ + afterY.y * cosZ) * pose.scaleY;
pz = afterY.z * pose.scaleZ;
return {
x: px + pose.translateX,
@@ -2437,6 +2449,10 @@ function normalizePipelinePose(metadata: Record<string, unknown>) {
const translateY = readNumberOption(translation.y ?? translation.translateY ?? metadata.translateY);
const translateZ = readNumberOption(translation.z ?? translation.translateZ ?? metadata.translateZ);
const scale = readNumberOption(metadata.scale ?? poseSource.scale);
const axisScale = readNestedObject(metadata.axisScale ?? metadata.axisScales ?? metadata.stretchScale);
const scaleX = readNumberOption(axisScale.x ?? axisScale.scaleX ?? metadata.scaleX);
const scaleY = readNumberOption(axisScale.y ?? axisScale.scaleY ?? metadata.scaleY);
const scaleZ = readNumberOption(axisScale.z ?? axisScale.scaleZ ?? metadata.scaleZ);
if (rotationX !== undefined) pose.rotateX = rotationX;
if (rotationY !== undefined) pose.rotateY = rotationY;
@@ -2445,6 +2461,9 @@ function normalizePipelinePose(metadata: Record<string, unknown>) {
if (translateY !== undefined) pose.translateY = translateY;
if (translateZ !== undefined) pose.translateZ = translateZ;
if (scale !== undefined) pose.scale = scale;
if (scaleX !== undefined) pose.scaleX = scaleX;
if (scaleY !== undefined) pose.scaleY = scaleY;
if (scaleZ !== undefined) pose.scaleZ = scaleZ;
(['X', 'Y', 'Z'] as const).forEach((axis) => {
const lowerAxis = axis.toLowerCase();
@@ -2518,9 +2537,9 @@ function rotatedModelSizeForPose(bounds: ModelBoundsRecord, pose: ModelPoseValue
y: afterX.y,
z: -afterX.x * sinY + afterX.z * cosY,
};
px = afterY.x * cosZ - afterY.y * sinZ;
py = afterY.x * sinZ + afterY.y * cosZ;
pz = afterY.z;
px = (afterY.x * cosZ - afterY.y * sinZ) * pose.scaleX;
py = (afterY.x * sinZ + afterY.y * cosZ) * pose.scaleY;
pz = afterY.z * pose.scaleZ;
rotatedBounds.min.x = Math.min(rotatedBounds.min.x, px);
rotatedBounds.min.y = Math.min(rotatedBounds.min.y, py);
rotatedBounds.min.z = Math.min(rotatedBounds.min.z, pz);

View File

@@ -31,6 +31,9 @@ const defaultModelPose: ModelPose = {
translateY: 0,
translateZ: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
flipX: false,
flipY: false,
flipZ: false,
@@ -583,6 +586,18 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
</div>
))}
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px] font-black text-slate-500">
{[
{ label: '轴拉伸 X', value: comparePose.scaleX },
{ label: '轴拉伸 Y', value: comparePose.scaleY },
{ label: '轴拉伸 Z', value: comparePose.scaleZ },
].map((item) => (
<div key={item.label} className="rounded-lg border border-slate-100 bg-white px-3 py-2">
<span className="mr-2 text-slate-400">{item.label}</span>
<span className="font-mono text-slate-800">{formatPoseNumber(item.value)}</span>
</div>
))}
</div>
{result && !resultHasPoseChange && (
<div className="mt-4 rounded-lg border border-amber-100 bg-amber-50 px-4 py-3 text-sm font-bold text-amber-800">

View File

@@ -53,6 +53,9 @@ interface ModelPose {
translateY: number;
translateZ: number;
scale: number;
scaleX: number;
scaleY: number;
scaleZ: number;
flipX: boolean;
flipY: boolean;
flipZ: boolean;
@@ -101,6 +104,9 @@ const defaultModelPose: ModelPose = {
translateY: 0,
translateZ: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
flipX: false,
flipY: false,
flipZ: false,
@@ -124,12 +130,18 @@ const modelPoseLimits: Record<ModelPoseKey, { min: number; max: number }> = {
translateY: { min: -2, max: 2 },
translateZ: { min: -2, max: 2 },
scale: { min: 0.5, max: 2.5 },
scaleX: { min: 0.2, max: 8 },
scaleY: { min: 0.2, max: 8 },
scaleZ: { min: 0.2, max: 8 },
};
const modelPoseStepPrecision: Partial<Record<ModelPoseKey, number>> = {
translateX: 3,
translateY: 3,
translateZ: 3,
scale: 3,
scaleX: 3,
scaleY: 3,
scaleZ: 3,
};
function clampModelPoseValue(key: ModelPoseKey, value: number) {
@@ -161,6 +173,9 @@ function clampModelPose(next: ModelPose): ModelPose {
translateY: clampModelPoseValue('translateY', next.translateY),
translateZ: clampModelPoseValue('translateZ', next.translateZ),
scale: clampModelPoseValue('scale', next.scale),
scaleX: clampModelPoseValue('scaleX', next.scaleX),
scaleY: clampModelPoseValue('scaleY', next.scaleY),
scaleZ: clampModelPoseValue('scaleZ', next.scaleZ),
flipX: Boolean(next.flipX),
flipY: Boolean(next.flipY),
flipZ: Boolean(next.flipZ),
@@ -699,8 +714,12 @@ function NativeStlViewer({
scene.add(fillLight);
const poseGroup = new THREE.Group();
const axisScaleGroup = new THREE.Group();
const rotationScaleGroup = new THREE.Group();
const pivotGroup = new THREE.Group();
poseGroup.add(pivotGroup);
rotationScaleGroup.add(pivotGroup);
axisScaleGroup.add(rotationScaleGroup);
poseGroup.add(axisScaleGroup);
let baseScale = 1;
scene.add(poseGroup);
let loaded = 0;
@@ -774,7 +793,7 @@ function NativeStlViewer({
pivotGroup.position.set(0, 0, 0);
baseScale = 4.2 / maxSize;
const initialPoseScale = baseScale * poseRef.current.scale;
pivotGroup.scale.set(
rotationScaleGroup.scale.set(
poseRef.current.flipX ? -initialPoseScale : initialPoseScale,
poseRef.current.flipY ? -initialPoseScale : initialPoseScale,
poseRef.current.flipZ ? -initialPoseScale : initialPoseScale,
@@ -803,13 +822,14 @@ function NativeStlViewer({
if (disposed) return;
const currentPose = poseRef.current;
poseGroup.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ);
pivotGroup.rotation.set(
axisScaleGroup.scale.set(currentPose.scaleX, currentPose.scaleY, currentPose.scaleZ);
rotationScaleGroup.rotation.set(
THREE.MathUtils.degToRad(currentPose.rotateX),
THREE.MathUtils.degToRad(currentPose.rotateY),
THREE.MathUtils.degToRad(currentPose.rotateZ),
);
const poseScale = baseScale * currentPose.scale;
pivotGroup.scale.set(
rotationScaleGroup.scale.set(
currentPose.flipX ? -poseScale : poseScale,
currentPose.flipY ? -poseScale : poseScale,
currentPose.flipZ ? -poseScale : poseScale,
@@ -1353,6 +1373,9 @@ export default function ProjectLibrary({
translateY: 0,
translateZ: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
}));
};
@@ -2143,8 +2166,11 @@ export default function ProjectLibrary({
{ key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.001, value: modelPose.translateY, minus: '-0.001', plus: '+0.001', delta: 0.001 },
{ key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.001, value: modelPose.translateZ, minus: '-0.001', plus: '+0.001', delta: 0.001 },
{ key: 'scale' as const, label: '缩放', min: 0.5, max: 2.5, step: 0.001, value: modelPose.scale, minus: '-0.001', plus: '+0.001', delta: 0.001 },
{ key: 'scaleX' as const, label: '轴拉伸 X', min: 0.2, max: 8, step: 0.001, value: modelPose.scaleX, minus: '-0.001', plus: '+0.001', delta: 0.001 },
{ key: 'scaleY' as const, label: '轴拉伸 Y', min: 0.2, max: 8, step: 0.001, value: modelPose.scaleY, minus: '-0.001', plus: '+0.001', delta: 0.001 },
{ key: 'scaleZ' as const, label: '轴拉伸 Z', min: 0.2, max: 8, step: 0.001, value: modelPose.scaleZ, minus: '-0.001', plus: '+0.001', delta: 0.001 },
].map((item) => (
<div key={item.key} className="grid grid-cols-[48px_48px_1fr_48px_42px] items-center gap-2">
<div key={item.key} className="grid grid-cols-[62px_48px_1fr_48px_42px] items-center gap-2">
<span className="text-[10px] font-bold text-slate-500">{item.label}</span>
<button
onClick={() => nudgeModelPose(item.key, -item.delta)}
@@ -2366,6 +2392,9 @@ export default function ProjectLibrary({
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TY {formatPoseCompactValue(latestResultPose.translateY, 3)}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TZ {formatPoseCompactValue(latestResultPose.translateZ, 3)}</span>
<span className="col-span-3 rounded-lg bg-slate-50 px-2 py-1.5">Scale {formatPoseCompactValue(latestResultPose.scale, 3)}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">SX {formatPoseCompactValue(latestResultPose.scaleX, 3)}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">SY {formatPoseCompactValue(latestResultPose.scaleY, 3)}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">SZ {formatPoseCompactValue(latestResultPose.scaleZ, 3)}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FX {latestResultPose.flipX ? '开' : '关'}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FY {latestResultPose.flipY ? '开' : '关'}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FZ {latestResultPose.flipZ ? '开' : '关'}</span>

View File

@@ -41,6 +41,7 @@ type ModelPoseKey = Exclude<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
type ModelPoseFlipKey = Extract<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
type PoseDraftValues = Record<ModelPoseKey, string>;
type AxisKey = 'x' | 'y' | 'z';
type StretchMode = 'uniform' | 'axis';
interface AxisVector2D {
dx: number;
@@ -60,7 +61,17 @@ interface WorkspaceLoadState {
error: string;
}
const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale'];
const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale', 'scaleX', 'scaleY', 'scaleZ'];
const axisScalePoseKeys: Record<AxisKey, Extract<ModelPoseKey, 'scaleX' | 'scaleY' | 'scaleZ'>> = {
x: 'scaleX',
y: 'scaleY',
z: 'scaleZ',
};
const axisTranslatePoseKeys: Record<AxisKey, Extract<ModelPoseKey, 'translateX' | 'translateY' | 'translateZ'>> = {
x: 'translateX',
y: 'translateY',
z: 'translateZ',
};
const modelPoseFlipOptions: Array<{ key: ModelPoseFlipKey; label: string; axis: string; icon: typeof FlipHorizontal2 }> = [
{ key: 'flipX', label: '镜像 X', axis: 'X', icon: FlipHorizontal2 },
{ key: 'flipY', label: '镜像 Y', axis: 'Y', icon: FlipVertical2 },
@@ -92,6 +103,9 @@ const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: num
translateY: { min: -2, max: 2, step: 0.001, minus: '-0.001', plus: '+0.001' },
translateZ: { min: -2, max: 2, step: 0.001, minus: '-0.001', plus: '+0.001' },
scale: { min: 0.5, max: 3, step: 0.001, minus: '-0.001', plus: '+0.001' },
scaleX: { min: 0.2, max: 8, step: 0.001, minus: '-0.001', plus: '+0.001' },
scaleY: { min: 0.2, max: 8, step: 0.001, minus: '-0.001', plus: '+0.001' },
scaleZ: { min: 0.2, max: 8, step: 0.001, minus: '-0.001', plus: '+0.001' },
};
const poseRepeatDelayMs = 240;
const poseRepeatIntervalMs = 55;
@@ -100,6 +114,9 @@ const poseRepeatDeltaMultiplier: Partial<Record<ModelPoseKey, number>> = {
translateY: 5,
translateZ: 5,
scale: 5,
scaleX: 5,
scaleY: 5,
scaleZ: 5,
};
const defaultModelPose: ModelPose = {
@@ -110,6 +127,9 @@ const defaultModelPose: ModelPose = {
translateY: 0,
translateZ: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
flipX: false,
flipY: false,
flipZ: false,
@@ -646,11 +666,15 @@ export function FusionThreeView({
const fusionRoot = new THREE.Group();
const dicomGroup = new THREE.Group();
const modelTranslateGroup = new THREE.Group();
const modelAxisScaleGroup = new THREE.Group();
const modelPoseGroup = new THREE.Group();
const modelPivot = new THREE.Group();
modelPoseGroup.add(modelPivot);
modelAxisScaleGroup.add(modelPoseGroup);
modelTranslateGroup.add(modelAxisScaleGroup);
fusionRoot.add(dicomGroup);
fusionRoot.add(modelPoseGroup);
fusionRoot.add(modelTranslateGroup);
scene.add(fusionRoot);
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
@@ -791,7 +815,8 @@ export function FusionThreeView({
modelBounds.visible = showBounds;
modelPivot.add(modelBounds);
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
modelPoseGroup.position.set(0, 0, 0);
modelTranslateGroup.position.set(0, 0, 0);
modelAxisScaleGroup.scale.set(1, 1, 1);
modelPivot.position.set(0, 0, 0);
setLoadProgress(100);
setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件');
@@ -897,11 +922,12 @@ export function FusionThreeView({
THREE.MathUtils.degToRad(pose.rotateY),
THREE.MathUtils.degToRad(pose.rotateZ),
);
modelPoseGroup.position.set(
modelTranslateGroup.position.set(
pose.translateX,
pose.translateY,
pose.translateZ,
);
modelAxisScaleGroup.scale.set(pose.scaleX, pose.scaleY, pose.scaleZ);
const poseScale = modelBaseScale * pose.scale;
modelPoseGroup.scale.set(
pose.flipX ? -poseScale : poseScale,
@@ -1080,10 +1106,14 @@ function CutSectionPreview({
scene.add(rimLight);
const fusionRoot = new THREE.Group();
const modelTranslateGroup = new THREE.Group();
const modelAxisScaleGroup = new THREE.Group();
const modelPoseGroup = new THREE.Group();
const modelPivot = new THREE.Group();
modelPoseGroup.add(modelPivot);
fusionRoot.add(modelPoseGroup);
modelAxisScaleGroup.add(modelPoseGroup);
modelTranslateGroup.add(modelAxisScaleGroup);
fusionRoot.add(modelTranslateGroup);
scene.add(fusionRoot);
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
@@ -1172,7 +1202,8 @@ function CutSectionPreview({
}
});
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.98;
modelPoseGroup.position.set(0, 0, 0);
modelTranslateGroup.position.set(0, 0, 0);
modelAxisScaleGroup.scale.set(1, 1, 1);
modelPivot.position.set(0, 0, 0);
});
@@ -1263,7 +1294,8 @@ function CutSectionPreview({
THREE.MathUtils.degToRad(pose.rotateY),
THREE.MathUtils.degToRad(pose.rotateZ),
);
modelPoseGroup.position.set(pose.translateX, pose.translateY, pose.translateZ);
modelTranslateGroup.position.set(pose.translateX, pose.translateY, pose.translateZ);
modelAxisScaleGroup.scale.set(pose.scaleX, pose.scaleY, pose.scaleZ);
const poseScale = modelBaseScale * pose.scale;
modelPoseGroup.scale.set(
pose.flipX ? -poseScale : poseScale,
@@ -1520,9 +1552,9 @@ function transformPointForPose(x: number, y: number, z: number, metrics: ModelSc
y: afterX.y,
z: -afterX.x * sinY + afterX.z * cosY,
};
px = afterY.x * cosZ - afterY.y * sinZ;
py = afterY.x * sinZ + afterY.y * cosZ;
pz = afterY.z;
px = (afterY.x * cosZ - afterY.y * sinZ) * pose.scaleX;
py = (afterY.x * sinZ + afterY.y * cosZ) * pose.scaleY;
pz = afterY.z * pose.scaleZ;
return {
x: px + pose.translateX,
@@ -2440,6 +2472,9 @@ export function VoxelizationMappingView({
modelPose.translateY,
modelPose.translateZ,
modelPose.scale,
modelPose.scaleX,
modelPose.scaleY,
modelPose.scaleZ,
modelPose.flipX,
modelPose.flipY,
modelPose.flipZ,
@@ -2826,7 +2861,7 @@ export default function ReverseWorkspace({
const [saveStatus, setSaveStatus] = useState('');
const [exporting, setExporting] = useState(false);
const [exportProgress, setExportProgress] = useState({ active: false, percent: 0, phase: '' });
const [stretchingAxis, setStretchingAxis] = useState<AxisKey | null>(null);
const [stretchingState, setStretchingState] = useState<{ mode: StretchMode; axis: AxisKey } | null>(null);
const modelBoundsCacheRef = useRef(new Map<string, { min: THREE.Vector3; max: THREE.Vector3 }>());
const [workspaceLoadState, setWorkspaceLoadState] = useState<WorkspaceLoadState>({
ready: false,
@@ -3096,7 +3131,8 @@ export default function ReverseWorkspace({
return bounds;
};
const applyModelStretchByAxis = async (axis: AxisKey, options: { silentInitial?: boolean } = {}) => {
const applyModelStretchByAxis = async (axis: AxisKey, options: { mode?: StretchMode; silentInitial?: boolean } = {}) => {
const mode = options.mode ?? 'uniform';
if (!project || !fusionVolume) {
setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型');
return;
@@ -3107,7 +3143,7 @@ export default function ReverseWorkspace({
return;
}
setStretchingAxis(axis);
setStretchingState({ mode, axis });
setFusionError('');
try {
const bounds = await loadGlobalModelBounds();
@@ -3131,15 +3167,32 @@ 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 axisFitScale = dicomSize[axis] / (rotatedAxisSize * baseScale);
const containmentScale = Math.min(
dicomSize.x / (Math.max(rotatedSize.x, 1e-6) * baseScale),
dicomSize.y / (Math.max(rotatedSize.y, 1e-6) * baseScale),
dicomSize.z / (Math.max(rotatedSize.z, 1e-6) * baseScale),
);
const nextScale = clampPoseValue('scale', Math.min(axisFitScale, containmentScale));
const nextPose = { ...modelPose, scale: nextScale };
updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true });
let nextPose: ModelPose;
if (mode === 'axis') {
const scaleKey = axisScalePoseKeys[axis];
const translateKey = axisTranslatePoseKeys[axis];
const nextAxisScale = clampPoseValue(scaleKey, dicomSize[axis] / (rotatedAxisSize * baseScale * modelPose.scale));
nextPose = {
...modelPose,
[scaleKey]: nextAxisScale,
[translateKey]: 0,
};
updateModelPose(
{ [scaleKey]: nextAxisScale, [translateKey]: 0 } as Partial<ModelPose>,
{ markCustom: !options.silentInitial, keepStatus: true },
);
} else {
const axisScale = Math.max(modelPose[axisScalePoseKeys[axis]], 1e-6);
const axisFitScale = dicomSize[axis] / (rotatedAxisSize * baseScale * axisScale);
const containmentScale = Math.min(
dicomSize.x / (Math.max(rotatedSize.x, 1e-6) * baseScale * Math.max(modelPose.scaleX, 1e-6)),
dicomSize.y / (Math.max(rotatedSize.y, 1e-6) * baseScale * Math.max(modelPose.scaleY, 1e-6)),
dicomSize.z / (Math.max(rotatedSize.z, 1e-6) * baseScale * Math.max(modelPose.scaleZ, 1e-6)),
);
const nextScale = clampPoseValue('scale', Math.min(axisFitScale, containmentScale));
nextPose = { ...modelPose, scale: nextScale };
updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true });
}
setPoseImportStatus('');
if (options.silentInitial) {
savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
@@ -3158,7 +3211,7 @@ export default function ReverseWorkspace({
} catch (error) {
setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败');
} finally {
setStretchingAxis(null);
setStretchingState(null);
}
};
@@ -3295,6 +3348,9 @@ export default function ReverseWorkspace({
modelPose.translateY,
modelPose.translateZ,
modelPose.scale,
modelPose.scaleX,
modelPose.scaleY,
modelPose.scaleZ,
]);
const clampPoseValue = (key: ModelPoseKey, value: number) => {
@@ -3411,6 +3467,9 @@ export default function ReverseWorkspace({
translateY: 0,
translateZ: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
}));
setSelectedPoseId('custom');
setPoseImportStatus('');
@@ -3921,24 +3980,41 @@ export default function ReverseWorkspace({
<span className="text-[10px] font-mono text-slate-400">
Layer: {displaySliceRange.start}-{displaySliceRange.end}/{project?.dicomCount ?? 0}
</span>
<div className="flex items-center gap-1 rounded-xl bg-slate-100 p-1">
<div className="flex flex-wrap 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}
key={`uniform-${axis}`}
onClick={() => void applyModelStretchByAxis(axis, { mode: 'uniform' })}
disabled={!stretchEnabled || stretchingState !== null}
className={`rounded-lg px-2 py-1 text-[9px] font-bold transition ${
stretchEnabled && stretchingAxis === null
stretchEnabled && stretchingState === 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()}拉伸`}
{stretchingState?.mode === 'uniform' && stretchingState.axis === axis ? '...' : `${axis.toUpperCase()}拉伸`}
</button>
))}
<span className="mx-0.5 h-4 w-px bg-slate-200" />
<span className="hidden px-1 text-[9px] font-bold text-slate-400 2xl:inline"></span>
{(['x', 'y', 'z'] as AxisKey[]).map((axis) => (
<button
key={`axis-${axis}`}
onClick={() => void applyModelStretchByAxis(axis, { mode: 'axis' })}
disabled={!stretchEnabled || stretchingState !== null}
className={`rounded-lg px-2 py-1 text-[9px] font-bold transition ${
stretchEnabled && stretchingState === null
? 'bg-white text-emerald-600 shadow-sm hover:text-emerald-700'
: 'cursor-not-allowed text-slate-300'
}`}
title={stretchEnabled ? `仅沿融合坐标 ${axis.toUpperCase()} 轴拉伸,并将平移 ${axis.toUpperCase()} 置 0` : '仅当旋转 X/Y/Z 均为 90° 的整数倍时可用'}
>
{stretchingState?.mode === 'axis' && stretchingState.axis === axis ? '...' : `${axis.toUpperCase()}`}
</button>
))}
</div>
@@ -4186,8 +4262,11 @@ export default function ReverseWorkspace({
{ key: 'translateY' as const, label: '平移 Y', value: modelPose.translateY },
{ key: 'translateZ' as const, label: '平移 Z', value: modelPose.translateZ },
{ key: 'scale' as const, label: '缩放', value: modelPose.scale },
{ key: 'scaleX' as const, label: '轴拉伸 X', value: modelPose.scaleX },
{ key: 'scaleY' as const, label: '轴拉伸 Y', value: modelPose.scaleY },
{ key: 'scaleZ' as const, label: '轴拉伸 Z', value: modelPose.scaleZ },
].map((item) => (
<div key={item.key} className="grid grid-cols-[44px_28px_1fr_28px_72px] items-center gap-2 text-[10px] font-bold text-slate-500">
<div key={item.key} className="grid grid-cols-[58px_28px_1fr_28px_72px] items-center gap-2 text-[10px] font-bold text-slate-500">
<span>{item.label}</span>
<button
onPointerDown={() => {

View File

@@ -46,6 +46,9 @@ export interface ModelPose {
translateY: number;
translateZ: number;
scale: number;
scaleX: number;
scaleY: number;
scaleZ: number;
flipX: boolean;
flipY: boolean;
flipZ: boolean;