3 Commits

15 changed files with 782 additions and 42 deletions

View File

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

View File

@@ -31,6 +31,9 @@ const defaultModelPose: ModelPose = {
translateY: 0, translateY: 0,
translateZ: 0, translateZ: 0,
scale: 1, scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
flipX: false, flipX: false,
flipY: false, flipY: false,
flipZ: false, flipZ: false,
@@ -583,6 +586,18 @@ export default function AutoMatchWorkspace({ projectId, initialPose, onOpenRever
</div> </div>
))} ))}
</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 && ( {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"> <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; translateY: number;
translateZ: number; translateZ: number;
scale: number; scale: number;
scaleX: number;
scaleY: number;
scaleZ: number;
flipX: boolean; flipX: boolean;
flipY: boolean; flipY: boolean;
flipZ: boolean; flipZ: boolean;
@@ -101,6 +104,9 @@ const defaultModelPose: ModelPose = {
translateY: 0, translateY: 0,
translateZ: 0, translateZ: 0,
scale: 1, scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
flipX: false, flipX: false,
flipY: false, flipY: false,
flipZ: false, flipZ: false,
@@ -124,12 +130,18 @@ const modelPoseLimits: Record<ModelPoseKey, { min: number; max: number }> = {
translateY: { min: -2, max: 2 }, translateY: { min: -2, max: 2 },
translateZ: { min: -2, max: 2 }, translateZ: { min: -2, max: 2 },
scale: { min: 0.5, max: 2.5 }, 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>> = { const modelPoseStepPrecision: Partial<Record<ModelPoseKey, number>> = {
translateX: 3, translateX: 3,
translateY: 3, translateY: 3,
translateZ: 3, translateZ: 3,
scale: 3, scale: 3,
scaleX: 3,
scaleY: 3,
scaleZ: 3,
}; };
function clampModelPoseValue(key: ModelPoseKey, value: number) { function clampModelPoseValue(key: ModelPoseKey, value: number) {
@@ -161,6 +173,9 @@ function clampModelPose(next: ModelPose): ModelPose {
translateY: clampModelPoseValue('translateY', next.translateY), translateY: clampModelPoseValue('translateY', next.translateY),
translateZ: clampModelPoseValue('translateZ', next.translateZ), translateZ: clampModelPoseValue('translateZ', next.translateZ),
scale: clampModelPoseValue('scale', next.scale), scale: clampModelPoseValue('scale', next.scale),
scaleX: clampModelPoseValue('scaleX', next.scaleX),
scaleY: clampModelPoseValue('scaleY', next.scaleY),
scaleZ: clampModelPoseValue('scaleZ', next.scaleZ),
flipX: Boolean(next.flipX), flipX: Boolean(next.flipX),
flipY: Boolean(next.flipY), flipY: Boolean(next.flipY),
flipZ: Boolean(next.flipZ), flipZ: Boolean(next.flipZ),
@@ -699,8 +714,12 @@ function NativeStlViewer({
scene.add(fillLight); scene.add(fillLight);
const poseGroup = new THREE.Group(); const poseGroup = new THREE.Group();
const axisScaleGroup = new THREE.Group();
const rotationScaleGroup = new THREE.Group();
const pivotGroup = new THREE.Group(); const pivotGroup = new THREE.Group();
poseGroup.add(pivotGroup); rotationScaleGroup.add(pivotGroup);
axisScaleGroup.add(rotationScaleGroup);
poseGroup.add(axisScaleGroup);
let baseScale = 1; let baseScale = 1;
scene.add(poseGroup); scene.add(poseGroup);
let loaded = 0; let loaded = 0;
@@ -774,7 +793,7 @@ function NativeStlViewer({
pivotGroup.position.set(0, 0, 0); pivotGroup.position.set(0, 0, 0);
baseScale = 4.2 / maxSize; baseScale = 4.2 / maxSize;
const initialPoseScale = baseScale * poseRef.current.scale; const initialPoseScale = baseScale * poseRef.current.scale;
pivotGroup.scale.set( rotationScaleGroup.scale.set(
poseRef.current.flipX ? -initialPoseScale : initialPoseScale, poseRef.current.flipX ? -initialPoseScale : initialPoseScale,
poseRef.current.flipY ? -initialPoseScale : initialPoseScale, poseRef.current.flipY ? -initialPoseScale : initialPoseScale,
poseRef.current.flipZ ? -initialPoseScale : initialPoseScale, poseRef.current.flipZ ? -initialPoseScale : initialPoseScale,
@@ -803,13 +822,14 @@ function NativeStlViewer({
if (disposed) return; if (disposed) return;
const currentPose = poseRef.current; const currentPose = poseRef.current;
poseGroup.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ); 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.rotateX),
THREE.MathUtils.degToRad(currentPose.rotateY), THREE.MathUtils.degToRad(currentPose.rotateY),
THREE.MathUtils.degToRad(currentPose.rotateZ), THREE.MathUtils.degToRad(currentPose.rotateZ),
); );
const poseScale = baseScale * currentPose.scale; const poseScale = baseScale * currentPose.scale;
pivotGroup.scale.set( rotationScaleGroup.scale.set(
currentPose.flipX ? -poseScale : poseScale, currentPose.flipX ? -poseScale : poseScale,
currentPose.flipY ? -poseScale : poseScale, currentPose.flipY ? -poseScale : poseScale,
currentPose.flipZ ? -poseScale : poseScale, currentPose.flipZ ? -poseScale : poseScale,
@@ -1353,6 +1373,9 @@ export default function ProjectLibrary({
translateY: 0, translateY: 0,
translateZ: 0, translateZ: 0,
scale: 1, 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: '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: '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: '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) => ( ].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> <span className="text-[10px] font-bold text-slate-500">{item.label}</span>
<button <button
onClick={() => nudgeModelPose(item.key, -item.delta)} 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">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="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="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">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">FY {latestResultPose.flipY ? '开' : '关'}</span>
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FZ {latestResultPose.flipZ ? '开' : '关'}</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 ModelPoseFlipKey = Extract<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
type PoseDraftValues = Record<ModelPoseKey, string>; type PoseDraftValues = Record<ModelPoseKey, string>;
type AxisKey = 'x' | 'y' | 'z'; type AxisKey = 'x' | 'y' | 'z';
type StretchMode = 'uniform' | 'axis';
interface AxisVector2D { interface AxisVector2D {
dx: number; dx: number;
@@ -60,7 +61,17 @@ interface WorkspaceLoadState {
error: string; 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 }> = [ const modelPoseFlipOptions: Array<{ key: ModelPoseFlipKey; label: string; axis: string; icon: typeof FlipHorizontal2 }> = [
{ key: 'flipX', label: '镜像 X', axis: 'X', icon: FlipHorizontal2 }, { key: 'flipX', label: '镜像 X', axis: 'X', icon: FlipHorizontal2 },
{ key: 'flipY', label: '镜像 Y', axis: 'Y', icon: FlipVertical2 }, { 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' }, 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' }, 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' }, 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 poseRepeatDelayMs = 240;
const poseRepeatIntervalMs = 55; const poseRepeatIntervalMs = 55;
@@ -100,6 +114,9 @@ const poseRepeatDeltaMultiplier: Partial<Record<ModelPoseKey, number>> = {
translateY: 5, translateY: 5,
translateZ: 5, translateZ: 5,
scale: 5, scale: 5,
scaleX: 5,
scaleY: 5,
scaleZ: 5,
}; };
const defaultModelPose: ModelPose = { const defaultModelPose: ModelPose = {
@@ -110,6 +127,9 @@ const defaultModelPose: ModelPose = {
translateY: 0, translateY: 0,
translateZ: 0, translateZ: 0,
scale: 1, scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
flipX: false, flipX: false,
flipY: false, flipY: false,
flipZ: false, flipZ: false,
@@ -646,11 +666,15 @@ export function FusionThreeView({
const fusionRoot = new THREE.Group(); const fusionRoot = new THREE.Group();
const dicomGroup = new THREE.Group(); const dicomGroup = new THREE.Group();
const modelTranslateGroup = new THREE.Group();
const modelAxisScaleGroup = new THREE.Group();
const modelPoseGroup = new THREE.Group(); const modelPoseGroup = new THREE.Group();
const modelPivot = new THREE.Group(); const modelPivot = new THREE.Group();
modelPoseGroup.add(modelPivot); modelPoseGroup.add(modelPivot);
modelAxisScaleGroup.add(modelPoseGroup);
modelTranslateGroup.add(modelAxisScaleGroup);
fusionRoot.add(dicomGroup); fusionRoot.add(dicomGroup);
fusionRoot.add(modelPoseGroup); fusionRoot.add(modelTranslateGroup);
scene.add(fusionRoot); scene.add(fusionRoot);
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1); const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
@@ -791,7 +815,8 @@ export function FusionThreeView({
modelBounds.visible = showBounds; modelBounds.visible = showBounds;
modelPivot.add(modelBounds); modelPivot.add(modelBounds);
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92; 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); modelPivot.position.set(0, 0, 0);
setLoadProgress(100); setLoadProgress(100);
setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件'); setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件');
@@ -897,11 +922,12 @@ export function FusionThreeView({
THREE.MathUtils.degToRad(pose.rotateY), THREE.MathUtils.degToRad(pose.rotateY),
THREE.MathUtils.degToRad(pose.rotateZ), THREE.MathUtils.degToRad(pose.rotateZ),
); );
modelPoseGroup.position.set( modelTranslateGroup.position.set(
pose.translateX, pose.translateX,
pose.translateY, pose.translateY,
pose.translateZ, pose.translateZ,
); );
modelAxisScaleGroup.scale.set(pose.scaleX, pose.scaleY, pose.scaleZ);
const poseScale = modelBaseScale * pose.scale; const poseScale = modelBaseScale * pose.scale;
modelPoseGroup.scale.set( modelPoseGroup.scale.set(
pose.flipX ? -poseScale : poseScale, pose.flipX ? -poseScale : poseScale,
@@ -1080,10 +1106,14 @@ function CutSectionPreview({
scene.add(rimLight); scene.add(rimLight);
const fusionRoot = new THREE.Group(); const fusionRoot = new THREE.Group();
const modelTranslateGroup = new THREE.Group();
const modelAxisScaleGroup = new THREE.Group();
const modelPoseGroup = new THREE.Group(); const modelPoseGroup = new THREE.Group();
const modelPivot = new THREE.Group(); const modelPivot = new THREE.Group();
modelPoseGroup.add(modelPivot); modelPoseGroup.add(modelPivot);
fusionRoot.add(modelPoseGroup); modelAxisScaleGroup.add(modelPoseGroup);
modelTranslateGroup.add(modelAxisScaleGroup);
fusionRoot.add(modelTranslateGroup);
scene.add(fusionRoot); scene.add(fusionRoot);
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1); 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; 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); modelPivot.position.set(0, 0, 0);
}); });
@@ -1263,7 +1294,8 @@ function CutSectionPreview({
THREE.MathUtils.degToRad(pose.rotateY), THREE.MathUtils.degToRad(pose.rotateY),
THREE.MathUtils.degToRad(pose.rotateZ), 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; const poseScale = modelBaseScale * pose.scale;
modelPoseGroup.scale.set( modelPoseGroup.scale.set(
pose.flipX ? -poseScale : poseScale, pose.flipX ? -poseScale : poseScale,
@@ -1520,9 +1552,9 @@ function transformPointForPose(x: number, y: number, z: number, metrics: ModelSc
y: afterX.y, y: afterX.y,
z: -afterX.x * sinY + afterX.z * cosY, z: -afterX.x * sinY + afterX.z * cosY,
}; };
px = afterY.x * cosZ - afterY.y * sinZ; px = (afterY.x * cosZ - afterY.y * sinZ) * pose.scaleX;
py = afterY.x * sinZ + afterY.y * cosZ; py = (afterY.x * sinZ + afterY.y * cosZ) * pose.scaleY;
pz = afterY.z; pz = afterY.z * pose.scaleZ;
return { return {
x: px + pose.translateX, x: px + pose.translateX,
@@ -2440,6 +2472,9 @@ export function VoxelizationMappingView({
modelPose.translateY, modelPose.translateY,
modelPose.translateZ, modelPose.translateZ,
modelPose.scale, modelPose.scale,
modelPose.scaleX,
modelPose.scaleY,
modelPose.scaleZ,
modelPose.flipX, modelPose.flipX,
modelPose.flipY, modelPose.flipY,
modelPose.flipZ, modelPose.flipZ,
@@ -2826,7 +2861,7 @@ export default function ReverseWorkspace({
const [saveStatus, setSaveStatus] = useState(''); const [saveStatus, setSaveStatus] = useState('');
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [exportProgress, setExportProgress] = useState({ active: false, percent: 0, phase: '' }); 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 modelBoundsCacheRef = useRef(new Map<string, { min: THREE.Vector3; max: THREE.Vector3 }>());
const [workspaceLoadState, setWorkspaceLoadState] = useState<WorkspaceLoadState>({ const [workspaceLoadState, setWorkspaceLoadState] = useState<WorkspaceLoadState>({
ready: false, ready: false,
@@ -3096,7 +3131,8 @@ export default function ReverseWorkspace({
return bounds; 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) { if (!project || !fusionVolume) {
setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型'); setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型');
return; return;
@@ -3107,7 +3143,7 @@ export default function ReverseWorkspace({
return; return;
} }
setStretchingAxis(axis); setStretchingState({ mode, axis });
setFusionError(''); setFusionError('');
try { try {
const bounds = await loadGlobalModelBounds(); 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 baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92;
const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6); const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6);
const axisFitScale = dicomSize[axis] / (rotatedAxisSize * baseScale); let nextPose: ModelPose;
const containmentScale = Math.min( if (mode === 'axis') {
dicomSize.x / (Math.max(rotatedSize.x, 1e-6) * baseScale), const scaleKey = axisScalePoseKeys[axis];
dicomSize.y / (Math.max(rotatedSize.y, 1e-6) * baseScale), const translateKey = axisTranslatePoseKeys[axis];
dicomSize.z / (Math.max(rotatedSize.z, 1e-6) * baseScale), const nextAxisScale = clampPoseValue(scaleKey, dicomSize[axis] / (rotatedAxisSize * baseScale * modelPose.scale));
); nextPose = {
const nextScale = clampPoseValue('scale', Math.min(axisFitScale, containmentScale)); ...modelPose,
const nextPose = { ...modelPose, scale: nextScale }; [scaleKey]: nextAxisScale,
updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true }); [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(''); setPoseImportStatus('');
if (options.silentInitial) { if (options.silentInitial) {
savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({ savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
@@ -3158,7 +3211,7 @@ export default function ReverseWorkspace({
} catch (error) { } catch (error) {
setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败'); setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败');
} finally { } finally {
setStretchingAxis(null); setStretchingState(null);
} }
}; };
@@ -3295,6 +3348,9 @@ export default function ReverseWorkspace({
modelPose.translateY, modelPose.translateY,
modelPose.translateZ, modelPose.translateZ,
modelPose.scale, modelPose.scale,
modelPose.scaleX,
modelPose.scaleY,
modelPose.scaleZ,
]); ]);
const clampPoseValue = (key: ModelPoseKey, value: number) => { const clampPoseValue = (key: ModelPoseKey, value: number) => {
@@ -3411,6 +3467,9 @@ export default function ReverseWorkspace({
translateY: 0, translateY: 0,
translateZ: 0, translateZ: 0,
scale: 1, scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
})); }));
setSelectedPoseId('custom'); setSelectedPoseId('custom');
setPoseImportStatus(''); setPoseImportStatus('');
@@ -3921,24 +3980,41 @@ export default function ReverseWorkspace({
<span className="text-[10px] font-mono text-slate-400"> <span className="text-[10px] font-mono text-slate-400">
Layer: {displaySliceRange.start}-{displaySliceRange.end}/{project?.dicomCount ?? 0} Layer: {displaySliceRange.start}-{displaySliceRange.end}/{project?.dicomCount ?? 0}
</span> </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"> <span className="hidden items-center gap-1 px-1 text-[9px] font-bold text-slate-400 2xl:flex">
<Maximize2 size={11} /> <Maximize2 size={11} />
</span> </span>
{(['x', 'y', 'z'] as AxisKey[]).map((axis) => ( {(['x', 'y', 'z'] as AxisKey[]).map((axis) => (
<button <button
key={axis} key={`uniform-${axis}`}
onClick={() => void applyModelStretchByAxis(axis)} onClick={() => void applyModelStretchByAxis(axis, { mode: 'uniform' })}
disabled={!stretchEnabled || stretchingAxis !== null} disabled={!stretchEnabled || stretchingState !== null}
className={`rounded-lg px-2 py-1 text-[9px] font-bold transition ${ 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' ? 'bg-white text-blue-600 shadow-sm hover:text-blue-700'
: 'cursor-not-allowed text-slate-300' : 'cursor-not-allowed text-slate-300'
}`} }`}
title={stretchEnabled ? `${axis.toUpperCase()} 方向自动等比例拉伸模型` : '仅当旋转 X/Y/Z 均为 90° 的整数倍时可用'} 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> </button>
))} ))}
</div> </div>
@@ -4186,8 +4262,11 @@ export default function ReverseWorkspace({
{ key: 'translateY' as const, label: '平移 Y', value: modelPose.translateY }, { key: 'translateY' as const, label: '平移 Y', value: modelPose.translateY },
{ key: 'translateZ' as const, label: '平移 Z', value: modelPose.translateZ }, { key: 'translateZ' as const, label: '平移 Z', value: modelPose.translateZ },
{ key: 'scale' as const, label: '缩放', value: modelPose.scale }, { 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) => ( ].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> <span>{item.label}</span>
<button <button
onPointerDown={() => { onPointerDown={() => {

View File

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

View File

@@ -0,0 +1,59 @@
# 实现方案-2026-06-04-09-46-36
## 实现方案文档路径
`工程分析/实现方案-2026-06-04-09-46-36.md`
## 修改目标
完成一次无源码变更的重新部署,让用户可以通过 `http://192.168.3.11:4000/` 访问当前程序。
## 涉及路径
- `WebSite/`
- `WebSite/package.json`
- `工程分析/需求分析-2026-06-04-09-46-36.md`
- `工程分析/实现方案-2026-06-04-09-46-36.md`
- `工程分析/测试方案-2026-06-04-09-46-36.md`
- `工程分析/经验记录.md`
## 技术路线
1. 检查工作区状态、部署脚本、已有 `tmux` 会话和 `4000` 端口占用。
2.`WebSite` 执行 `npm run build`,生成最新生产构建。
3. 使用或重建 `tmux` 会话 `revoxelseg-dicom`,在其中启动:
`npm run serve -- --host 0.0.0.0 --port 4000`
4. 验证本机健康检查、本机首页和局域网首页。
5. 追加经验记录,并仅提交本次工程分析文档与经验记录。
## 执行步骤
- 执行前再次确认已读 `工程分析/经验记录.md`
- 若存在旧 `revoxelseg-dicom` 会话,先发送 `Ctrl-C` 停止旧服务,再启动新服务。
- 若不存在该会话,则创建新的 detached `tmux` 会话。
- 使用 `curl` 验证:
- `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/`
- 检查 `tmux` 会话和端口监听状态,确认服务长期运行。
## 兼容性与回滚方案
- 若新服务启动失败,保留构建和启动日志,停止新会话后回滚到上一次可用部署命令。
- 若端口被非本项目进程占用,先记录占用来源,再决定是否停止或改端口。
- 若局域网访问失败但本机访问成功,优先排查监听地址、服务器防火墙和网络路由。
## 预计文件变更
- 新增本次三件套文档。
- 追加 `工程分析/经验记录.md`
- `WebSite/dist/` 会因构建更新,但不纳入本次文档备份 commit。
- 不预计修改源码。
## 提交与部署策略
- 先完成构建、服务重启和访问验证。
- 再追加经验记录。
- 仅暂存本次工程分析文档和 `工程分析/经验记录.md`
- commit message 使用:`2026-06-04-09-46-36 部署并验证局域网 4000 服务`

View File

@@ -0,0 +1,71 @@
# 实现方案-2026-06-23-12-07-35
## 实现方案文档路径
`工程分析/实现方案-2026-06-23-12-07-35.md`
## 修改目标
完成一次无业务源码变更的重新部署,使当前程序可通过 `http://192.168.3.11:4000/` 访问,并保留本次操作的工程分析记录。
## 涉及路径
- `AGENTS.md`
- `README.md`
- `WebSite/README.md`
- `WebSite/package.json`
- `WebSite/vite.config.ts`
- `WebSite/server.ts`
- `工程分析/代码编纂工作流.md`
- `工程分析/工程整体分析.md`
- `工程分析/经验记录.md`
- `工程分析/需求分析-2026-06-23-12-07-35.md`
- `工程分析/实现方案-2026-06-23-12-07-35.md`
- `工程分析/测试方案-2026-06-23-12-07-35.md`
## 技术路线
- 通过仓库文档、脚本和服务入口确认项目是 Express + Vite 的前后端一体服务。
- 使用 `npm run build` 生成生产构建产物。
- 使用 `tmux` 会话 `revoxelseg-dicom` 启动 `npm run serve -- --host 0.0.0.0 --port 4000`
- 使用 `curl` 同时验证本机地址和目标局域网地址的 API 与首页。
- 仅将本次工程分析文档和经验记录纳入 Git 备份提交。
## 执行步骤
1. 检查工作区状态、Node/npm 版本、依赖目录、目标网卡地址、`4000` 端口和 `tmux` 会话。
2.`WebSite/` 执行 `npm run build`
3. 如存在旧 `revoxelseg-dicom` 会话,先确认并重建该会话;如不存在则创建新会话。
4. 在会话内执行 `npm run serve -- --host 0.0.0.0 --port 4000`
5. 验证:
- `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/`
6. 检查 `tmux` 会话日志和 `4000` 端口监听状态。
7. 追加 `工程分析/经验记录.md` 的 A/B/C/D 四段式记录。
8. 暂存并提交本次工程分析文档commit message 使用 `2026-06-23-12-07-35 部署并验证局域网 4000 服务`
## 兼容性与回滚方案
- 如果构建失败,不重启旧服务,保留错误并汇报。
- 如果新服务启动失败,查看 `tmux` 日志和端口占用,停止失败会话后恢复上一条可用部署命令。
- 如果 `4000` 被非本项目进程占用,先记录占用来源,再决定是否停止或改用既有服务验证。
- 如果 `192.168.3.11` 不在本机网卡上,应停止局域网地址部署验证并报告网络前置条件不满足。
## 预计文件变更
- 新增:
- `工程分析/需求分析-2026-06-23-12-07-35.md`
- `工程分析/实现方案-2026-06-23-12-07-35.md`
- `工程分析/测试方案-2026-06-23-12-07-35.md`
- 追加:
- `工程分析/经验记录.md`
- 运行构建可能更新或生成 `WebSite/dist/`,该目录为运行态构建产物,不纳入本次文档备份提交。
## 提交与部署策略
- 先完成构建、部署和访问验证,再追加经验记录。
- 使用 Git 仅提交本次工程分析文档和经验记录。
- 不提交 DICOM/STL 数据、`WebSite/data/``WebSite/exports/``WebSite/dist/` 或无关工作区变化。
- 提交信息:`2026-06-23-12-07-35 部署并验证局域网 4000 服务`

View 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`

View File

@@ -0,0 +1,47 @@
# 测试方案-2026-06-04-09-46-36
## 测试方案文档路径
`工程分析/测试方案-2026-06-04-09-46-36.md`
## 静态检查
- 检查 `WebSite/package.json``build``serve` 脚本存在。
- 检查本机网卡包含 `192.168.3.11`
- 检查 `tmux` 会话 `revoxelseg-dicom``4000` 端口状态,避免重复服务冲突。
## 构建检查
-`WebSite` 执行 `npm run build`
- 通过 Vite 构建输出确认无构建错误。
## 关键业务场景验证
- 验证 `http://127.0.0.1:4000/api/health` 返回健康状态。
- 验证 `http://127.0.0.1:4000/` 返回首页 HTML。
- 验证 `http://192.168.3.11:4000/api/health` 返回健康状态。
- 验证 `http://192.168.3.11:4000/` 返回首页 HTML。
## 医学影像数据相关边界验证
- 本次仅重新部署,不修改 DICOM/STL 读取、预览、融合、分割映射和 Mask 导出逻辑。
- 不重新生成医学影像导出结果,避免把运行态产物混入提交。
## 部署验证
- 服务命令:`npm run serve -- --host 0.0.0.0 --port 4000`
- 运行载体:`tmux` 会话 `revoxelseg-dicom`
- 端口验证:确认 `4000` 端口由当前 Node 服务监听。
- 页面验证:确认局域网首页可通过 HTTP 访问。
## Git/Gitea 备份验证
- 使用 `git status --short` 确认只暂存本次工程分析文档和经验记录。
- commit message 包含 `2026-06-04-09-46-36`
- 避免提交 `WebSite/dist/`、医学数据、导出文件和无关工作区变化。
## 风险与回归关注点
- 如果构建失败,应停止部署并汇报具体错误。
- 如果服务启动后本机成功但局域网失败,应继续检查网络访问链路。
- 如果 `tmux` 会话中存在旧命令残留,应清屏或重新启动,确保会话内运行的是本次部署命令。

View File

@@ -0,0 +1,65 @@
# 测试方案-2026-06-23-12-07-35
## 测试方案文档路径
`工程分析/测试方案-2026-06-23-12-07-35.md`
## 静态检查
- 检查 `WebSite/package.json` 中存在 `build``serve` 脚本。
- 检查 `WebSite/server.ts` 会解析 `--host``--port` 参数并提供 `/api/health`
- 检查本机是否包含目标网卡地址 `192.168.3.11`
- 检查当前 Git 工作区,避免提交无关变更。
## 构建检查
-`WebSite/` 执行 `npm run build`
- 如果构建失败,停止部署并记录错误。
## 关键业务场景验证
- 验证首页 HTML 可正常返回,确认前端入口可访问。
- 验证 `/api/health` 返回健康状态,确认 Express API 可用。
- 可选验证 `/api/projects` 返回项目列表,确认默认项目状态可读。
## 医学影像数据相关边界验证
- 本次仅重新部署,不修改 DICOM/STL 解析、融合、分割映射或 Mask 导出逻辑。
- 检查默认演示数据目录存在并记录数量,不生成新的医学导出文件。
- 避免将 `Head_CT_DICOM/``Head_CT_ReConstruct/``WebSite/data/``WebSite/exports/` 混入 Git 提交。
## 部署验证
- 服务命令:`npm run serve -- --host 0.0.0.0 --port 4000`
- 运行载体:`tmux` 会话 `revoxelseg-dicom`
- 端口验证:确认 `4000` 端口由当前 Node 服务监听。
- 访问验证:
- `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 备份验证
- 只暂存本次工程分析三件套和 `工程分析/经验记录.md`
- 提交后用 `git show --stat --oneline HEAD` 验证 commit message 包含 `2026-06-23-12-07-35` 和部署简述。
## 风险与回归关注点
- 如果 `tmux` 会话已存在,需确认旧服务关闭后新服务能稳定启动。
- 如果局域网 IP 不属于当前机器,服务即使监听成功也无法通过目标地址访问。
- 如果只验证 localhost不能代表 `192.168.3.11` 可访问。
- 如果误提交构建产物或医学数据,会污染备份提交,需要在提交前复查 staged 文件。
## 执行结果
- `npm run lint`:通过,`tsc --noEmit` 无错误。
- `npm run build`通过Vite 成功生成 `dist/`;存在 chunk 大于 500 kB 的体积提示,不影响本次部署。
- 本机网卡:已确认 `ens34` 包含 `192.168.3.11/24`
- 端口与会话:`tmux` 会话 `revoxelseg-dicom` 已创建,`4000` 端口由当前 Node 进程监听在 `0.0.0.0:4000`
- 启动命令:`NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 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当前返回 2 个项目,默认项目 `head-ct-demo` 包含 300 张 DICOM 与 9 个 STL。

View 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 历史中。

View File

@@ -1995,3 +1995,57 @@ C. 解决问题方案
D. 后续如何避免问题 D. 后续如何避免问题
面向 Windows 用户直接查看的复杂导出包优先使用 ZIP不要用需要 PAX 扩展的 tar.gz如果必须使用 tar.gz必须避免非 ASCII 和长路径,或确认目标解压器正确隐藏 PAX。导出包内部结构应以机器可读和人工可读为准分类分割文件名保持类别名映射关系单独放入 `labels.json` 面向 Windows 用户直接查看的复杂导出包优先使用 ZIP不要用需要 PAX 扩展的 tar.gz如果必须使用 tar.gz必须避免非 ASCII 和长路径,或确认目标解压器正确隐藏 PAX。导出包内部结构应以机器可读和人工可读为准分类分割文件名保持类别名映射关系单独放入 `labels.json`
## 2026-06-04-09-46-36 局域网部署要同时确认网卡地址和监听地址
A. 具体问题
用户要求通过 `http://192.168.3.11:4000/` 访问程序。本次部署前没有现存 `revoxelseg-dicom` tmux 会话,`4000` 端口也未被占用,需要重新创建长期运行会话并确保服务对局域网开放。
B. 产生问题原因
局域网访问不仅依赖服务启动成功还依赖本机实际存在目标网卡地址、Node 服务监听 `0.0.0.0` 而不是只监听 `127.0.0.1`。如果只验证本机 localhost可能遗漏局域网地址不可达的问题。
C. 解决问题方案
先确认本机网卡包含 `192.168.3.11`,再执行 `npm run build`。构建通过后创建 `tmux` 会话 `revoxelseg-dicom`,在 `WebSite` 中启动 `npm run serve -- --host 0.0.0.0 --port 4000`。随后同时验证 `127.0.0.1``192.168.3.11``/api/health` 与首页,确认均返回正常响应。
D. 后续如何避免问题
后续做局域网部署时,部署验证必须同时覆盖三点:目标 IP 是否属于当前机器、端口是否监听在 `0.0.0.0`、目标 IP 的 API 和首页是否都能访问。若没有既有 tmux 会话,可直接创建同名会话;若端口被占用,则先记录占用来源再处理。
## 2026-06-23-12-07-35 无源码变更部署仍要记录实际运行命令
A. 具体问题
用户要求阅读整个工作区并部署到 `http://192.168.3.11:4000/`。本次没有业务源码变更,但仍需要完成构建、长期运行托管、局域网访问验证和文档备份提交。
B. 产生问题原因
部署类需求会改变运行态服务,即使不改源码也可能受端口占用、`tmux` 会话残留、网卡地址、生产/开发服务模式和前端入口访问影响。如果只记录“服务已启动”,后续排查时无法确认实际运行命令和验证覆盖面。
C. 解决问题方案
按工作流创建本次需求分析、实现方案和测试方案。执行前再次阅读部署相关经验,确认 `192.168.3.11` 属于本机网卡且 `4000` 端口空闲。通过 `npm run lint``npm run build` 后,在 `tmux` 会话 `revoxelseg-dicom` 中使用 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000` 启动服务,并分别验证 `127.0.0.1``192.168.3.11``/api/health` 和首页均返回 HTTP 200。
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 坐标轴上生效,并在前后端保持完全一致。

View File

@@ -0,0 +1,50 @@
# 需求分析-2026-06-04-09-46-36
## 开始时间
2026-06-04-09-46-36
## 原始需求摘要
用户要求部署当前 ReVoxelSeg DICOM 程序,并希望通过 `http://192.168.3.11:4000/` 访问。
## 业务目标
- 使用项目约定的 `WebSite` 一体服务完成重新部署。
- 服务监听 `0.0.0.0:4000`,确保局域网地址 `192.168.3.11:4000` 可访问。
- 完成构建、健康检查、首页访问和部署状态验证。
## 输入与输出
- 输入:当前工作区代码、`WebSite/package.json` 部署脚本、已有 `tmux` 会话和本机网络环境。
- 输出:运行在 `4000` 端口的 WebSite 服务、可访问的局域网地址、部署验证结果和文档备份 commit。
## 影响范围
- 运行态影响:`WebSite/dist/` 构建产物、`tmux` 会话 `revoxelseg-dicom` 中的 Node 服务。
- 文档影响:新增本次需求分析、实现方案、测试方案,并追加经验记录。
- 不涉及源码逻辑、医学影像数据、STL 数据和导出算法变更。
## 关键约束
- 必须执行 `工程分析/代码编纂工作流.md`
- 部署优先使用:
- `cd WebSite`
- `npm run build`
- `npm run serve -- --host 0.0.0.0 --port 4000`
- 长期运行优先沿用 `tmux` 会话 `revoxelseg-dicom`
- 部署后至少验证 `/api/health` 和首页。
- 文档备份 commit message 必须包含 `2026-06-04-09-46-36`
## 风险点
- `4000` 端口可能已有旧服务或其他进程占用,需要先识别并重启到当前版本。
- 若构建失败,不能直接复用旧服务冒充新部署。
- 若本机可访问但局域网地址不可访问,可能是监听地址、防火墙或网络链路问题。
- 构建产物和运行态文件不应混入文档备份 commit。
## 待确认问题或默认假设
- 默认当前工作区代码就是用户希望部署的版本。
- 默认无需修改源码,只做构建、启动、验证和工程分析文档备份。
- 默认使用本机实际网卡地址 `192.168.3.11` 作为局域网访问地址。

View File

@@ -0,0 +1,53 @@
# 需求分析-2026-06-23-12-07-35
## 开始时间
2026-06-23-12-07-35
## 原始需求摘要
用户要求阅读整个工作区,并部署当前程序,使其可以通过 `http://192.168.3.11:4000/` 被访问。
## 业务目标
- 确认当前仓库结构、部署约束、运行脚本和历史经验。
- 在不修改业务源码的前提下完成一次当前版本构建。
- 使用长期运行方式启动前后端一体服务。
- 确认本机与局域网目标地址均可访问。
## 输入与输出
- 输入:当前工作区 `/home/wkmgc/Desktop/ReVoxelSeg_DICOM``WebSite/package.json` 脚本、`WebSite/server.ts` 服务入口、DICOM/STL 默认演示数据、已有 `tmux` 和端口状态。
- 输出:运行在 `0.0.0.0:4000` 的 ReVoxelSeg DICOM 服务、验证通过的 `http://127.0.0.1:4000/``http://192.168.3.11:4000/` 访问结果、本次工程分析文档和备份提交。
## 影响范围
- 运行态:`WebSite/dist/` 构建产物、`tmux` 会话 `revoxelseg-dicom``4000` 端口监听进程。
- 文档:新增本次 `需求分析``实现方案``测试方案`,并在完成后追加 `工程分析/经验记录.md`
- 不修改业务源码、医学影像数据、STL 模型数据、运行态状态和导出结果。
## 关键约束
- 必须先执行 `工程分析/代码编纂工作流.md`
- 必须阅读 `工程分析/工程整体分析.md``工程分析/经验记录.md`
- 最终执行方案前需再次确认已读 `工程分析/经验记录.md`
- 部署优先使用:
- `cd WebSite`
- `npm run build`
- `npm run serve -- --host 0.0.0.0 --port 4000`
- 长期运行优先沿用 `tmux` 会话 `revoxelseg-dicom`
- Git/Gitea 备份 commit message 必须包含时间戳和简要描述。
## 风险点
- `4000` 端口可能被旧服务或 Docker 容器占用。
- `tmux` 会话中可能残留旧命令,需要确认实际启动的是当前构建版本。
- 目标 IP `192.168.3.11` 必须属于当前机器网卡,否则局域网地址无法访问。
- 如果只验证 `/api/health`,可能遗漏首页 HTML 或前端资源访问异常。
- 不能将医学数据、运行态产物或无关工作区变更混入备份提交。
## 待确认问题或默认假设
- 默认假设当前工作区代码即为用户希望部署的版本。
- 默认假设用户要求的是本机 `tmux` 方式部署,而不是 Docker/FRPC 公网部署。
- 默认假设本次仅做部署、验证和工程分析文档维护,不做功能代码变更。

View 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轴` 单轴拉伸按钮。
- 默认单轴拉伸会把对应平移自动归零,而不是仅提示用户手动归零。