Update DICOM UPP fusion workspace view

This commit is contained in:
Codex
2026-05-30 10:37:44 +08:00
parent 86e25d0a51
commit bdd0db2a4c
3 changed files with 422 additions and 170 deletions

View File

@@ -8,8 +8,7 @@ const BODY_LABELS = {
head_neck: "头颈部",
chest: "胸部",
upper_abdomen: "上腹部",
lower_abdomen: "腹部",
pelvis: "盆腔",
abdomen_pelvis: "腹部",
};
const DEFAULT_POSE = {
@@ -40,6 +39,13 @@ const POSE_CONTROLS = [
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
const CASE_PAGE_SIZE = 20;
const FUSION_BASE_EXTENT = 4.6;
const AXIS_INSET_LENGTH = 17;
const DEFAULT_AXIS_PROJECTION = {
x: { dx: AXIS_INSET_LENGTH, dy: 0, opacity: 0.95 },
y: { dx: -10, dy: 10, opacity: 0.82 },
z: { dx: 0, dy: -AXIS_INSET_LENGTH, opacity: 0.95 },
};
const POSE_SIGNATURE_KEYS = [
"rotateX",
"rotateY",
@@ -84,8 +90,8 @@ const state = {
pose: { ...DEFAULT_POSE },
windowMode: "default",
fusionMode: "fusion",
fusionDetail: "low",
modelDetail: "standard",
fusionDetail: "high",
modelDetail: "ultra",
mappingMode: "result",
sliceIndex: 0,
sliceRangeStart: 0,
@@ -127,6 +133,7 @@ const state = {
mappingVersion: 0,
resizeObserver: null,
resizeScene: null,
axisProjectionSignature: "",
nudgeTimer: null,
nudgeDelayTimer: null,
};
@@ -147,6 +154,29 @@ function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function getDicomDisplaySliceNumber(sliceIndex, totalSlices) {
const total = Math.max(Math.round(Number(totalSlices) || 0), 0);
if (!total) return 0;
return total - clamp(Math.round(Number(sliceIndex) || 0), 0, total - 1);
}
function getDicomDisplayRange(startIndex, endIndex, totalSlices) {
const first = getDicomDisplaySliceNumber(startIndex, totalSlices);
const second = getDicomDisplaySliceNumber(endIndex, totalSlices);
return {
start: Math.min(first, second),
end: Math.max(first, second),
};
}
function setFusionWebglError(message = "") {
const overlay = $("fusionWebglError");
if (!overlay) return;
overlay.classList.toggle("hidden", !message);
const detail = overlay.querySelector("p");
if (detail && message) detail.textContent = message;
}
function setTopLoading(visible) {
$("topLoading").classList.toggle("hidden", !visible);
}
@@ -277,9 +307,11 @@ async function bootstrap() {
loginVisible(false);
try {
initScene();
setFusionWebglError("");
} catch (error) {
initGeometryFallback();
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
setFusionWebglError("三维融合视图暂不可用,请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与映射功能仍可继续使用。");
console.warn(error);
}
buildPoseControls();
@@ -601,8 +633,8 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const savedTransform = { ...(registration.transform || {}), scaleX: 1, scaleY: 1, scaleZ: 1 };
const keepPreviousPose = switchingModelOnly;
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
state.fusionDetail = state.fusionDetail || "low";
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "standard";
state.fusionDetail = registration.model_reference?.fusion_detail || state.fusionDetail || "high";
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "ultra";
state.autoResult = null;
state.lastAutoPose = null;
state.sliceIndex = 0;
@@ -1006,6 +1038,28 @@ function renderPoseControls() {
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
});
renderActiveHeader();
updateStretchButtons();
}
function isRightAngleRotation(value) {
const normalized = ((Number(value) || 0) % 90 + 90) % 90;
return normalized < 0.001 || Math.abs(normalized - 90) < 0.001;
}
function canStretchByAxis() {
return ["rotateX", "rotateY", "rotateZ"].every((key) => isRightAngleRotation(state.pose[key]));
}
function updateStretchButtons() {
const enabled = canStretchByAxis();
["x", "y", "z"].forEach((axis) => {
const button = $(`stretch${axis.toUpperCase()}Btn`);
if (!button) return;
button.disabled = !enabled;
button.title = enabled
? `${axis.toUpperCase()} 方向自动等比例拉伸模型`
: "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可用";
});
}
function updateSliceControl() {
@@ -1070,14 +1124,17 @@ function updateDicomPreview() {
function initScene() {
if (state.sceneReady) return;
const viewport = $("fusionViewport");
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setClearColor(0x000000, 1);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setClearColor(0x030712, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.localClippingEnabled = true;
viewport.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000);
camera.position.set(0, -8.5, 4.4);
scene.background = new THREE.Color(0x030712);
const camera = new THREE.PerspectiveCamera(45, 1, 0.05, 1000);
camera.position.set(0, -6.2, 4.6);
camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0);
@@ -1085,21 +1142,22 @@ function initScene() {
controls.enabled = false;
controls.target.set(0, 0, 0);
const ambient = new THREE.AmbientLight(0xffffff, 0.82);
const directional = new THREE.DirectionalLight(0xffffff, 1.8);
directional.position.set(3.5, -4, 6);
scene.add(ambient, directional);
const ambient = new THREE.AmbientLight(0xffffff, 0.72);
const keyLight = new THREE.DirectionalLight(0xffffff, 1.1);
keyLight.position.set(4, -5, 5);
const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.55);
fillLight.position.set(-4, 3, 2);
scene.add(ambient, keyLight, fillLight);
const dicomGroup = new THREE.Group();
const modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group();
const axisGroup = createSceneAxes();
const fusionRoot = new THREE.Group();
modelGroup.add(rawModelGroup);
fusionRoot.add(dicomGroup, modelGroup, axisGroup);
fusionRoot.add(dicomGroup, modelGroup);
scene.add(fusionRoot);
Object.assign(state, { renderer, camera, scene, controls, fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup, sceneReady: true });
Object.assign(state, { renderer, camera, scene, controls, fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup: null, sceneReady: true });
const resize = () => {
const rect = viewport.getBoundingClientRect();
@@ -1116,10 +1174,81 @@ function initScene() {
const animate = () => {
requestAnimationFrame(animate);
applySceneViewPose();
updateFusionAxisInset();
renderer.render(scene, camera);
};
animate();
wireFusionSceneDrag(renderer.domElement);
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
}
function axisProjectionSignature(projection) {
return ["x", "y", "z"]
.map((key) => {
const item = projection[key] || DEFAULT_AXIS_PROJECTION[key];
return `${Math.round(item.dx * 10)},${Math.round(item.dy * 10)},${Math.round(item.opacity * 100)}`;
})
.join("|");
}
function projectModelAxisDirections(camera, object) {
const origin = object.getWorldPosition(new THREE.Vector3());
const originProjected = origin.clone().project(camera);
const quaternion = object.getWorldQuaternion(new THREE.Quaternion());
const axisDirections = {
x: new THREE.Vector3(1, 0, 0),
y: new THREE.Vector3(0, 1, 0),
z: new THREE.Vector3(0, 0, 1),
};
const projectAxis = (direction) => {
const end = origin.clone().add(direction.applyQuaternion(quaternion).normalize().multiplyScalar(0.72));
const endProjected = end.project(camera);
const dx = endProjected.x - originProjected.x;
const dy = originProjected.y - endProjected.y;
const magnitude = Math.hypot(dx, dy);
if (magnitude < 0.0001) return { dx: 0, dy: -5, opacity: 0.5 };
return {
dx: (dx / magnitude) * AXIS_INSET_LENGTH,
dy: (dy / magnitude) * AXIS_INSET_LENGTH,
opacity: endProjected.z < originProjected.z ? 1 : 0.58,
};
};
return {
x: projectAxis(axisDirections.x),
y: projectAxis(axisDirections.y),
z: projectAxis(axisDirections.z),
};
}
function updateFusionAxisInset(projection = null) {
if (!projection) {
if (!state.camera || !state.modelGroup) projection = DEFAULT_AXIS_PROJECTION;
else {
state.fusionRoot?.updateMatrixWorld(true);
state.modelGroup.updateMatrixWorld(true);
projection = projectModelAxisDirections(state.camera, state.modelGroup);
}
}
const signature = axisProjectionSignature(projection);
if (state.axisProjectionSignature === signature) return;
state.axisProjectionSignature = signature;
const origin = { x: 25, y: 31 };
const items = {
x: { line: $("fusionAxisXLine"), text: $("fusionAxisXText"), group: $("fusionAxisX") },
y: { line: $("fusionAxisYLine"), text: $("fusionAxisYText"), group: $("fusionAxisY") },
z: { line: $("fusionAxisZLine"), text: $("fusionAxisZText"), group: $("fusionAxisZ") },
};
Object.entries(items).forEach(([key, item]) => {
const vector = projection[key] || DEFAULT_AXIS_PROJECTION[key];
const endX = origin.x + vector.dx;
const endY = origin.y + vector.dy;
item.group?.setAttribute("opacity", String(vector.opacity));
item.line?.setAttribute("x2", String(endX));
item.line?.setAttribute("y2", String(endY));
item.text?.setAttribute("x", String(endX + (vector.dx >= 0 ? 4 : -4)));
item.text?.setAttribute("y", String(endY + (vector.dy >= 0 ? 6 : -3)));
item.text?.setAttribute("text-anchor", vector.dx >= 0 ? "start" : "end");
});
}
function createAxisLabel(text, color) {
@@ -1168,7 +1297,7 @@ function initGeometryFallback() {
const rawModelGroup = new THREE.Group();
modelGroup.add(rawModelGroup);
fusionRoot.add(dicomGroup, modelGroup);
Object.assign(state, { fusionRoot, dicomGroup, modelGroup, rawModelGroup });
Object.assign(state, { fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup: null });
}
function applySceneViewPose() {
@@ -1257,6 +1386,7 @@ function clearFusion() {
state.modelLocalBounds = null;
$("fusionMeta").textContent = "DICOM - · STL -";
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
updateSliceControl();
resetMappingCanvas();
}
@@ -1274,17 +1404,20 @@ function updateFusionMeta() {
return;
}
const volume = state.volumeMeta;
$("fusionMeta").textContent = volume
? `DICOM ${volume.start + 1}-${volume.end + 1}/${volume.total} · STL ${selectedCount}`
: `DICOM - · STL ${selectedCount}`;
if (!volume) {
$("fusionMeta").textContent = `DICOM - · STL ${selectedCount}`;
return;
}
const displayRange = getDicomDisplayRange(volume.start, volume.end, volume.total);
$("fusionMeta").textContent = `DICOM ${displayRange.start}-${displayRange.end}/${volume.total} · STL ${selectedCount}`;
}
function fusionDetailSettings() {
return {
low: { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 },
medium: { planeOpacity: 1, sliceOpacity: 0.07, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 },
high: { planeOpacity: 1, sliceOpacity: 0.11, slicePlanes: 72, pointOpacity: 0, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 1024 },
}[state.fusionDetail] || { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 };
low: { planeOpacity: 0.42, sliceOpacity: 0.045, boxOpacity: 0.2, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 },
medium: { planeOpacity: 0.56, sliceOpacity: 0.075, boxOpacity: 0.26, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 },
high: { planeOpacity: 0.68, sliceOpacity: 0.11, boxOpacity: 0.32, slicePlanes: 72, pointOpacity: 0, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 1024 },
}[state.fusionDetail] || { planeOpacity: 0.42, sliceOpacity: 0.045, boxOpacity: 0.2, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 };
}
function modelDetailSettings() {
@@ -1311,7 +1444,7 @@ function applyFusionDetail() {
}
state.dicomSlicePlanes.forEach((plane) => {
if (!plane.material) return;
plane.material.opacity = detail.sliceOpacity;
plane.material.opacity = plane.userData?.rangeBoundary ? detail.planeOpacity : detail.sliceOpacity;
plane.material.needsUpdate = true;
});
}
@@ -1358,6 +1491,7 @@ async function loadFusion() {
clearFusion();
return;
}
$("fusionStatus").textContent = "正在构建三维融合场景...";
setFusionLoading(true, "正在读取 DICOM 切片范围", 8);
updateSliceControl();
try {
@@ -1415,6 +1549,38 @@ async function loadTexture(url) {
});
}
async function loadDicomFusionTexture(url) {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
const width = image.naturalWidth || image.width || 1;
const height = image.naturalHeight || image.height || 1;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height);
for (let index = 0; index < imageData.data.length; index += 4) {
const value = imageData.data[index];
imageData.data[index + 1] = value;
imageData.data[index + 2] = value;
imageData.data[index + 3] = value > 4 ? Math.min(235, 42 + Math.round(value * 0.76)) : 0;
}
context.putImageData(imageData, 0, 0);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1);
texture.needsUpdate = true;
return texture;
}
async function loadImageData(url, maxSize = 256) {
const image = await new Promise((resolve, reject) => {
const img = new Image();
@@ -1932,7 +2098,7 @@ async function buildDicomVolume(volume) {
state.dicomSlicePlanes = [];
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 };
const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
const sceneScale = 4.8 / maxPhysical;
const sceneScale = FUSION_BASE_EXTENT / maxPhysical;
const width = (physical.width || 1) * sceneScale;
const height = (physical.height || 1) * sceneScale;
const depth = Math.max((physical.depth || 1) * sceneScale, 0.18);
@@ -1940,13 +2106,10 @@ async function buildDicomVolume(volume) {
const total = Math.max(1, Number(volume.total || 1));
const indices = volume.indices || [];
const frames = volume.frames || [];
const centerPosition = indices.length
? indices.reduce((bestIndex, item, index) => {
const bestDistance = Math.abs(Number(indices[bestIndex] ?? centerIndex) - centerIndex);
const distance = Math.abs(Number(item ?? centerIndex) - centerIndex);
return distance < bestDistance ? index : bestIndex;
}, 0)
: 0;
const fusionDetail = fusionDetailSettings();
const rangeStart = clamp(Number(volume.start ?? indices[0] ?? centerIndex) || 0, 0, total - 1);
const rangeEnd = clamp(Number(volume.end ?? indices[indices.length - 1] ?? centerIndex) || 0, 0, total - 1);
const boundaryIndices = new Set([Math.min(rangeStart, rangeEnd), Math.max(rangeStart, rangeEnd)]);
state.volumeScene = {
width,
height,
@@ -1960,7 +2123,7 @@ async function buildDicomVolume(volume) {
const boxMesh = new THREE.Mesh(
new THREE.BoxGeometry(width, height, depth),
new THREE.MeshBasicMaterial({ color: 0x020617, transparent: true, opacity: 0.045, depthWrite: false }),
new THREE.MeshBasicMaterial({ color: 0x020617, transparent: true, opacity: fusionDetail.boxOpacity, depthWrite: false }),
);
state.dicomGroup.add(boxMesh);
const edges = new THREE.LineSegments(
@@ -1969,58 +2132,28 @@ async function buildDicomVolume(volume) {
);
state.dicomGroup.add(edges);
const localStart = Number(volume.start ?? indices[0] ?? centerIndex);
const localEnd = Number(volume.end ?? indices[indices.length - 1] ?? centerIndex);
const rangeDepth = Math.max(0.015, Math.abs(sliceToSceneZ(localEnd) - sliceToSceneZ(localStart)));
const rangeBox = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(width * 0.94, height * 0.94, rangeDepth)),
new THREE.LineBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 0.24 }),
);
rangeBox.position.z = (sliceToSceneZ(localStart) + sliceToSceneZ(localEnd)) / 2;
state.dicomGroup.add(rangeBox);
const centerFrame = (volume.frames || [])[centerPosition];
const fusionDetail = fusionDetailSettings();
if (centerFrame) {
const texture = await loadTexture(centerFrame);
const planeGeometry = new THREE.PlaneGeometry(width, height);
for (let frameIndex = 0; frameIndex < frames.length; frameIndex += 1) {
const dicomIndex = clamp(Number(indices[frameIndex] ?? centerIndex) || 0, 0, total - 1);
const isBoundary = boundaryIndices.has(dicomIndex) || frames.length === 1;
const texture = await loadDicomFusionTexture(frames[frameIndex]);
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(width, height),
planeGeometry,
new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: fusionDetail.planeOpacity,
opacity: isBoundary ? fusionDetail.planeOpacity : fusionDetail.sliceOpacity,
side: THREE.DoubleSide,
depthWrite: false,
depthTest: false,
depthTest: true,
}),
);
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
plane.renderOrder = 80;
plane.position.z = sliceToSceneZ(dicomIndex) + (isBoundary ? 0.006 : 0);
plane.renderOrder = isBoundary ? 16 : 4;
plane.userData.rangeBoundary = isBoundary;
state.dicomGroup.add(plane);
state.dicomPlane = plane;
}
if (frames.length > 1) {
const planeStep = Math.max(1, Math.ceil(frames.length / Math.max(1, fusionDetail.slicePlanes)));
for (let frameIndex = 0; frameIndex < frames.length; frameIndex += planeStep) {
if (frameIndex === centerPosition) continue;
const texture = await loadTexture(frames[frameIndex]);
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(width, height),
new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: fusionDetail.sliceOpacity,
side: THREE.DoubleSide,
depthWrite: false,
depthTest: true,
}),
);
plane.position.z = sliceToSceneZ(Number(indices[frameIndex] ?? centerIndex));
plane.renderOrder = 4;
state.dicomGroup.add(plane);
state.dicomSlicePlanes.push(plane);
}
state.dicomSlicePlanes.push(plane);
if (isBoundary && !state.dicomPlane) state.dicomPlane = plane;
}
if (fusionDetail.pointOpacity > 0) {
@@ -2076,7 +2209,7 @@ function updateBaseModelScaleFromBounds() {
const maxModelSize = Math.max(Number(size.x) || 0, Number(size.y) || 0, Number(size.z) || 0, 1);
const volumeSize = state.volumeScene
? Math.max(state.volumeScene.width, state.volumeScene.height, state.volumeScene.depth, 1)
: 4.8;
: FUSION_BASE_EXTENT;
state.baseModelScale = (volumeSize / maxModelSize) * 0.92;
}
@@ -2088,10 +2221,7 @@ function addModelBoundsFrame(size) {
new THREE.LineBasicMaterial({
color: 0xfacc15,
transparent: true,
opacity: 0.96,
depthTest: false,
depthWrite: false,
toneMapped: false,
opacity: 0.72,
}),
);
frame.name = "model-bounds";
@@ -2244,41 +2374,19 @@ function applyPose(poseInput = state.pose) {
function fitCamera() {
if (!state.camera) return;
applySceneViewPose();
const box = new THREE.Box3();
if (state.dicomGroup?.visible !== false) box.expandByObject(state.dicomGroup);
const modelBox = visibleModelBox();
if (modelBox) box.union(modelBox);
if (box.isEmpty()) {
state.camera.position.set(0, -8.5, 4.4);
state.camera.lookAt(0, 0, 0);
state.controls?.target.set(0, 0, 0);
state.controls?.update();
return;
}
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
const distance = Math.max(size.x, size.y, size.z, 1) * 1.8;
state.camera.position.set(center.x, center.y - distance, center.z + distance * 0.45);
state.camera.lookAt(center);
state.controls?.target.copy(center);
if (state.axisGroup) {
const axisScale = Math.max(size.x, size.y, size.z, 1) / 5.5;
state.axisGroup.scale.setScalar(axisScale);
state.axisGroup.position.set(
center.x - size.x * 0.46,
center.y - size.y * 0.46,
center.z - size.z * 0.42,
);
}
state.camera.position.set(0, -6.2, 4.6);
state.camera.up.set(0, 0, 1);
state.camera.lookAt(0, 0, 0);
state.controls?.target.set(0, 0, 0);
state.controls?.update();
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
}
function resetSceneView() {
state.viewPose = { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 };
applySceneViewPose();
fitCamera();
$("fusionStatus").textContent = state.volumeMeta ? "三维融合视角已复位" : "等待选择 DICOM 与 STL";
}
function resetPose(kind) {
@@ -2297,6 +2405,13 @@ function resetPose(kind) {
}
function stretchAxis(axis) {
if (!canStretchByAxis()) {
const message = "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可使用 XYZ 拉伸。";
setAutoState("XYZ 拉伸不可用", "warn");
$("autoResult").textContent = message;
updateStretchButtons();
return;
}
const modelBox = visibleModelBox();
if (!modelBox || !state.dicomSceneBox) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";