2026-06-23-13-38-48 新增逆向工作区单轴拉伸
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
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),
|
||||
dicomSize.y / (Math.max(rotatedSize.y, 1e-6) * baseScale),
|
||||
dicomSize.z / (Math.max(rotatedSize.z, 1e-6) * baseScale),
|
||||
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));
|
||||
const nextPose = { ...modelPose, scale: nextScale };
|
||||
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={() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
71
工程分析/实现方案-2026-06-23-13-38-48.md
Normal file
71
工程分析/实现方案-2026-06-23-13-38-48.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 实现方案-2026-06-23-13-38-48
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-06-23-13-38-48.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
新增模型位姿的单轴拉伸能力,使逆向工作区可按某一坐标轴非等比例拉伸模型,并保证缩放、保存、导出、项目库预览和自动微调匹配都使用该拉伸后的统一模型位姿。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/需求分析-2026-06-23-13-38-48.md`
|
||||
- `工程分析/实现方案-2026-06-23-13-38-48.md`
|
||||
- `工程分析/测试方案-2026-06-23-13-38-48.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 扩展 `ModelPose` / `ModelPoseValue`,新增 `scaleX`、`scaleY`、`scaleZ`,默认值为 `1`。
|
||||
- 统一归一化和 clamp 范围,让旧数据自动补齐新字段。
|
||||
- 前端三维视图和二维分割映射采用同一变换顺序:
|
||||
- 原始模型中心化。
|
||||
- 应用统一缩放、镜像和旋转。
|
||||
- 在融合坐标系下应用 `scaleX/scaleY/scaleZ` 单轴拉伸。
|
||||
- 应用平移。
|
||||
- 后端 `transformPointForExportPose` 使用相同变换顺序,保证导出和自动匹配评分一致。
|
||||
- 逆向工作区保留现有等比例自动拉伸,再新增单轴拉伸按钮;单轴拉伸时自动把对应平移置 0。
|
||||
- 自动微调匹配工作区不额外优化 `scaleX/scaleY/scaleZ`,但请求、评分、对比视图和保存结果保留并应用这些字段。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 扩展前后端位姿类型、默认值、格式化、归一化和 reset 行为。
|
||||
2. 修改 `ReverseWorkspace` 的模型变换、二维映射和工具栏按钮。
|
||||
3. 修改 `ProjectLibrary` 结果预览中的模型变换,兼容新字段。
|
||||
4. 修改 `server.ts` 的位姿归一化、管线参数解析、自动匹配评分和导出分割变换。
|
||||
5. 运行 `npm run lint` 与 `npm run build`。
|
||||
6. 重启 `tmux` 会话 `revoxelseg-dicom` 并验证本机/局域网访问。
|
||||
7. 追加经验记录并提交本次文档与源码备份 commit。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 兼容性:旧位姿不含 `scaleX/scaleY/scaleZ` 时默认按 `1` 处理,与旧行为完全一致。
|
||||
- 回滚:如新拉伸或导出异常,可回退本次 commit 并重新构建部署。
|
||||
- 数据风险:本次不修改医学数据;运行态 `state.json` 只会在用户实际保存项目时记录新字段。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改:
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
- 新增:
|
||||
- `工程分析/需求分析-2026-06-23-13-38-48.md`
|
||||
- `工程分析/实现方案-2026-06-23-13-38-48.md`
|
||||
- `工程分析/测试方案-2026-06-23-13-38-48.md`
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次源码和工程分析文档。
|
||||
- 不提交 DICOM/STL 数据、运行态 `WebSite/data/`、导出文件或无关变更。
|
||||
- commit message 使用:`2026-06-23-13-38-48 新增逆向工作区单轴拉伸`。
|
||||
- 构建通过后使用 `tmux` 会话 `revoxelseg-dicom` 重新部署 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
70
工程分析/测试方案-2026-06-23-13-38-48.md
Normal file
70
工程分析/测试方案-2026-06-23-13-38-48.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 测试方案-2026-06-23-13-38-48
|
||||
|
||||
## 测试方案文档路径
|
||||
|
||||
`工程分析/测试方案-2026-06-23-13-38-48.md`
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 执行 `npm run lint`,验证 TypeScript 类型。
|
||||
- 检查 `ModelPose` 前后端字段一致。
|
||||
- 检查旧位姿数据默认补齐 `scaleX/scaleY/scaleZ = 1`。
|
||||
|
||||
## 构建检查
|
||||
|
||||
- 在 `WebSite/` 执行 `npm run build`。
|
||||
- 确认 Vite 构建成功。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 逆向工作区仍能加载默认项目、DICOM/STL 和映射视图。
|
||||
- 现有等比例 `X拉伸/Y拉伸/Z拉伸` 行为保留。
|
||||
- 新增单轴拉伸在旋转 X/Y/Z 为 90 度整数倍时可用。
|
||||
- 单轴拉伸某轴后,对应 `平移 X/Y/Z` 自动为 `0`。
|
||||
- 调整统一 `缩放` 后,模型仍保留单轴拉伸比例。
|
||||
- 点击自动微调匹配时,传入的初始位姿包含单轴拉伸字段。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- 不修改 DICOM/STL 原始数据。
|
||||
- 不改变 DICOM 切片索引、窗宽窗位和模型文件读取逻辑。
|
||||
- 验证分割映射和导出使用同一位姿变换,避免前端显示与后端结果不一致。
|
||||
|
||||
## 部署验证
|
||||
|
||||
- 使用 `tmux` 会话 `revoxelseg-dicom` 重启服务。
|
||||
- 验证:
|
||||
- `http://127.0.0.1:4000/api/health`
|
||||
- `http://127.0.0.1:4000/`
|
||||
- `http://192.168.3.11:4000/api/health`
|
||||
- `http://192.168.3.11:4000/`
|
||||
|
||||
## Git/Gitea 备份验证
|
||||
|
||||
- 暂存本次源码和工程分析文档。
|
||||
- commit message 包含 `2026-06-23-13-38-48` 和 `新增逆向工作区单轴拉伸`。
|
||||
- 使用 `git show --stat --oneline HEAD` 核对提交范围。
|
||||
|
||||
## 风险与回归关注点
|
||||
|
||||
- 三维模型、二维映射、导出和自动匹配必须使用同一变换顺序。
|
||||
- 自动匹配候选只改统一缩放和平移时,必须保留单轴拉伸字段不丢失。
|
||||
- 单轴拉伸控件不能挤压现有左侧融合视图标题栏。
|
||||
- 旧项目状态文件缺少新字段时必须正常加载。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint`:通过,`tsc --noEmit` 无错误。
|
||||
- `npm run build`:通过,Vite 成功生成 `dist/`;仍有大 chunk 体积提示,不影响本次部署。
|
||||
- Playwright 桌面视口 `1440x900`:已登录并进入逆向工作区,点击 `X轴` 单轴拉伸按钮;页面包含 `X轴/Y轴/Z轴` 与 `轴拉伸 X` 控件;截图 `/tmp/revoxelseg-axis-stretch-desktop.png` 非背景像素约 513328。
|
||||
- Playwright 移动视口 `420x900`:已登录并进入逆向工作区,点击 `X轴` 单轴拉伸按钮;页面包含单轴拉伸控件;截图 `/tmp/revoxelseg-axis-stretch-mobile.png` 非背景像素约 263176。
|
||||
- Canvas 检查:桌面/移动视口的 DICOM 与映射 2D canvas 均有非空像素;WebGL canvas 截图可见,但 `readPixels` 返回 0,判断与 Three.js 默认绘图缓冲行为有关,因此以截图像素统计作为视觉验证补充。
|
||||
- 部署:已重启 `tmux` 会话 `revoxelseg-dicom`,运行 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
- 端口:`4000` 由当前 Node 进程监听在 `0.0.0.0:4000`。
|
||||
- `http://127.0.0.1:4000/api/health`:HTTP 200,返回 `ok: true`。
|
||||
- `http://127.0.0.1:4000/`:HTTP 200,返回 HTML。
|
||||
- `http://192.168.3.11:4000/api/health`:HTTP 200,返回 `ok: true`。
|
||||
- `http://192.168.3.11:4000/`:HTTP 200,返回 HTML。
|
||||
- `http://127.0.0.1:4000/api/projects`:HTTP 200,返回项目位姿已自动补齐 `scaleX/scaleY/scaleZ`,旧项目数据兼容。
|
||||
- Git 备份:本地 commit `e88b40a` 已完成,commit message 为 `2026-06-23-13-38-48 新增逆向工作区单轴拉伸`。
|
||||
- Gitea 推送:执行 `git push origin main` 失败,错误为无法读取 `http://192.168.31.5:5002` 的用户名凭据;本次源码与文档已保留在本地 Git 历史中。
|
||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -2031,3 +2031,21 @@ C. 解决问题方案
|
||||
D. 后续如何避免问题
|
||||
|
||||
后续无源码变更的重新部署也要记录实际启动命令、运行载体、端口监听地址、目标 IP 归属以及 API/首页双验证结果。若使用 `npm run build` 后部署,优先记录是否以 `NODE_ENV=production` 服务 `dist/`,避免开发中间件 Host 校验或源码热更新状态影响访问判断。
|
||||
|
||||
## 2026-06-23-13-38-48 单轴拉伸位姿字段必须统一变换顺序
|
||||
|
||||
A. 具体问题
|
||||
|
||||
用户要求在逆向工作区新增直接沿某一个轴拉伸模型的方式,并要求后续缩放和自动微调匹配都使用拉伸后的模型。旧位姿只有统一 `scale`,现有 X/Y/Z 拉伸也都是等比例缩放,无法表达非等比例的单轴变形。
|
||||
|
||||
B. 产生问题原因
|
||||
|
||||
模型位姿会同时进入三维融合视图、二维逆向分割映射、项目库回放、自动匹配评分和服务端导出。如果只在某个前端按钮里临时改变显示比例,保存、导出或自动匹配会丢失该变形;如果前端和后端的变换顺序不同,又会出现页面位置和导出 Label Map 不一致。
|
||||
|
||||
C. 解决问题方案
|
||||
|
||||
为 `ModelPose` / `ModelPoseValue` 增加 `scaleX`、`scaleY`、`scaleZ`,默认补齐为 `1`。统一采用“中心化后统一缩放与镜像、旋转、融合坐标系单轴拉伸、平移”的变换顺序。逆向工作区保留等比例 `X拉伸/Y拉伸/Z拉伸`,新增 `X轴/Y轴/Z轴` 单轴拉伸;单轴拉伸时自动把对应 `translateX/Y/Z` 置为 `0`。前端三维、二维映射、项目库预览和后端 `transformPointForExportPose` 使用同一套字段,自动匹配继续只优化平移与统一缩放,但保留并应用单轴拉伸后的基准模型。
|
||||
|
||||
D. 后续如何避免问题
|
||||
|
||||
后续新增位姿字段时,必须同步检查类型、默认值、状态归一化、导入/保存、三维渲染层级、二维映射、服务端导出坐标变换、自动匹配评分和项目库回放。涉及非等比例变换时要先明确是在模型局部轴还是融合/DICOM 坐标轴上生效,并在前后端保持完全一致。
|
||||
|
||||
55
工程分析/需求分析-2026-06-23-13-38-48.md
Normal file
55
工程分析/需求分析-2026-06-23-13-38-48.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 需求分析-2026-06-23-13-38-48
|
||||
|
||||
## 开始时间
|
||||
|
||||
2026-06-23-13-38-48
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户指出“逆向工作区”现有 `X拉伸`、`Y拉伸`、`Z拉伸` 都是等比例拉伸,希望新增一种可直接沿某一个轴拉伸模型的方式。该方式仍要求旋转 X/Y/Z 为 0 或 90 度整数倍,并且拉伸某轴时对应的平移 X/Y/Z 为 0;后续缩放、自动微调匹配工作区都应使用新拉伸后的模型。
|
||||
|
||||
## 业务目标
|
||||
|
||||
- 在保留现有等比例自动拉伸的基础上,新增单轴非等比例拉伸能力。
|
||||
- 单轴拉伸后的模型位姿要成为统一数据源,后续缩放、保存、导出、逆向分割映射和自动微调匹配都沿用该位姿。
|
||||
- 保持旧项目和旧位姿数据兼容,旧数据没有单轴拉伸字段时按 `1` 处理。
|
||||
|
||||
## 输入与输出
|
||||
|
||||
- 输入:当前 `WebSite/src/components/ReverseWorkspace.tsx` 的拉伸交互、`ModelPose` 类型、`server.ts` 位姿归一化/导出/自动匹配逻辑、项目库预览逻辑。
|
||||
- 输出:新增轴向拉伸系数、逆向工作区单轴拉伸按钮和状态反馈、前后端统一识别并应用单轴拉伸后的位姿。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 前端类型与组件:
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- 后端:
|
||||
- `WebSite/server.ts`
|
||||
- 工程文档:
|
||||
- 本次需求分析、实现方案、测试方案
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 关键约束
|
||||
|
||||
- 必须按 `工程分析/代码编纂工作流.md` 执行。
|
||||
- 单轴拉伸启用条件:
|
||||
- 旋转 X/Y/Z 均为 0 或 90 度整数倍。
|
||||
- 拉伸 X/Y/Z 时,对应平移 X/Y/Z 为 0。
|
||||
- 新增字段必须向后兼容,不破坏已有项目状态、位姿 JSON、自动匹配结果和导出包。
|
||||
- 自动匹配可继续只微调平移与统一缩放,但评分和结果展示必须基于单轴拉伸后的模型。
|
||||
|
||||
## 风险点
|
||||
|
||||
- 前端三维视图和二维映射如果使用不同变换顺序,会导致所见与导出/自动匹配不一致。
|
||||
- 后端导出和自动匹配若未同步新增轴向拉伸字段,会出现页面正确但结果错误。
|
||||
- 旧保存位姿缺少新字段时,如果归一化不完整,可能导致 `NaN` 或类型检查失败。
|
||||
- 单轴拉伸按钮过多可能挤压现有工具栏,需要保持紧凑可读。
|
||||
|
||||
## 待确认问题或默认假设
|
||||
|
||||
- 默认将新增单轴拉伸理解为 DICOM/融合视图坐标系下的 X/Y/Z 非等比例拉伸。
|
||||
- 默认保留现有 `X拉伸/Y拉伸/Z拉伸` 等比例行为,并新增另一组 `X轴/Y轴/Z轴` 单轴拉伸按钮。
|
||||
- 默认单轴拉伸会把对应平移自动归零,而不是仅提示用户手动归零。
|
||||
Reference in New Issue
Block a user