Compare commits
3 Commits
acdff763b5
...
5a9d09a5bf
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a9d09a5bf | |||
| 1e044fb349 | |||
| 60ebbf4196 |
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
59
工程分析/实现方案-2026-06-04-09-46-36.md
Normal file
59
工程分析/实现方案-2026-06-04-09-46-36.md
Normal 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 服务`。
|
||||||
71
工程分析/实现方案-2026-06-23-12-07-35.md
Normal file
71
工程分析/实现方案-2026-06-23-12-07-35.md
Normal 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 服务`。
|
||||||
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`。
|
||||||
47
工程分析/测试方案-2026-06-04-09-46-36.md
Normal file
47
工程分析/测试方案-2026-06-04-09-46-36.md
Normal 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` 会话中存在旧命令残留,应清屏或重新启动,确保会话内运行的是本次部署命令。
|
||||||
65
工程分析/测试方案-2026-06-23-12-07-35.md
Normal file
65
工程分析/测试方案-2026-06-23-12-07-35.md
Normal 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。
|
||||||
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 历史中。
|
||||||
54
工程分析/经验记录.md
54
工程分析/经验记录.md
@@ -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 坐标轴上生效,并在前后端保持完全一致。
|
||||||
|
|||||||
50
工程分析/需求分析-2026-06-04-09-46-36.md
Normal file
50
工程分析/需求分析-2026-06-04-09-46-36.md
Normal 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` 作为局域网访问地址。
|
||||||
53
工程分析/需求分析-2026-06-23-12-07-35.md
Normal file
53
工程分析/需求分析-2026-06-23-12-07-35.md
Normal 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 公网部署。
|
||||||
|
- 默认假设本次仅做部署、验证和工程分析文档维护,不做功能代码变更。
|
||||||
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